From f73677139d49c83c140b2bb7798e05d42511c54c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A2u=20Cao?= Date: Mon, 23 Feb 2026 22:01:46 +0400 Subject: [PATCH] Zoom to fit ways and relations into map view --- app/components/map.gjs | 55 +++++++++++++++++++++++++++- app/services/osm.js | 63 ++++++++++++++++++++++++--------- tests/unit/services/osm-test.js | 35 ++++++++++++++++++ 3 files changed, 136 insertions(+), 17 deletions(-) diff --git a/app/components/map.gjs b/app/components/map.gjs index b83b98b..29c691f 100644 --- a/app/components/map.gjs +++ b/app/components/map.gjs @@ -436,7 +436,11 @@ export default class MapComponent extends Component { void this.selectedPinElement.offsetWidth; this.selectedPinElement.classList.add('active'); - this.handlePinVisibility(coords); + if (selected.bbox) { + this.zoomToBbox(selected.bbox); + } else { + this.handlePinVisibility(coords); + } } else { this.selectedPinElement.classList.remove('active'); // Hide it effectively by moving it away or just relying on display:none in CSS @@ -444,6 +448,55 @@ export default class MapComponent extends Component { } }); + zoomToBbox(bbox) { + if (!this.mapInstance || !bbox) return; + + const view = this.mapInstance.getView(); + const size = this.mapInstance.getSize(); + + // Convert bbox to extent: [minx, miny, maxx, maxy] + const min = fromLonLat([bbox.minLon, bbox.minLat]); + const max = fromLonLat([bbox.maxLon, bbox.maxLat]); + const extent = [...min, ...max]; + + // Default padding for full screen: 15% on all sides (70% visible) + let padding = [ + size[1] * 0.15, // Top + size[0] * 0.15, // Right + size[1] * 0.15, // Bottom + size[0] * 0.15, // Left + ]; + + // Mobile: Bottom sheet covers 50% of the screen height + if (size[0] <= 768) { + // We want the geometry to be centered in the top 50% of the screen. + // Top padding: 15% of the VISIBLE height (size[1] * 0.5) + const visibleHeight = size[1] * 0.5; + const topPadding = visibleHeight * 0.15; + const bottomPadding = (size[1] * 0.5) + (visibleHeight * 0.15); // Sheet + padding + + padding[0] = topPadding; + padding[2] = bottomPadding; + } + // Desktop: Sidebar covers left side (approx 400px) + else if (this.args.isSidebarOpen) { + const sidebarWidth = 400; + const visibleWidth = size[0] - sidebarWidth; + + // Left padding: Sidebar + 15% of visible width + padding[3] = sidebarWidth + (visibleWidth * 0.15); + // Right padding: 15% of visible width + padding[1] = visibleWidth * 0.15; + } + + view.fit(extent, { + padding: padding, + duration: 1000, + easing: (t) => t * (2 - t), + maxZoom: 19, + }); + } + handlePinVisibility(coords) { if (!this.mapInstance) return; diff --git a/app/services/osm.js b/app/services/osm.js index f097424..e03d29a 100644 --- a/app/services/osm.js +++ b/app/services/osm.js @@ -171,8 +171,12 @@ out center; if (!mainElement) return null; + // Use a separate variable for the element we want to display (tags, id, specific coords) + // vs the element we use for geometry calculation (bbox). + let displayElement = mainElement; + // If it's a boundary relation, try to find the label or admin_centre node - // and use that as the main element (better coordinates and tags). + // and use that as the display element (better coordinates and tags). if (targetType === 'relation' && mainElement.members) { const labelMember = mainElement.members.find( (m) => m.role === 'label' && m.type === 'node' @@ -189,13 +193,14 @@ out center; String(el.id) === String(targetMember.ref) && el.type === 'node' ); if (targetNode) { - mainElement = targetNode; + displayElement = targetNode; } } } - let lat = mainElement.lat; - let lon = mainElement.lon; + let lat = displayElement.lat; + let lon = displayElement.lon; + let bbox = null; // If it's a way, calculate center from nodes if (targetType === 'way' && mainElement.nodes) { @@ -211,11 +216,23 @@ out center; .filter(Boolean); if (coords.length > 0) { - // Simple average center - const sumLat = coords.reduce((sum, c) => sum + c[1], 0); - const sumLon = coords.reduce((sum, c) => sum + c[0], 0); - lat = sumLat / coords.length; - lon = sumLon / coords.length; + // Only override lat/lon if we haven't switched to a specific display node + if (displayElement === mainElement) { + const sumLat = coords.reduce((sum, c) => sum + c[1], 0); + const sumLon = coords.reduce((sum, c) => sum + c[0], 0); + lat = sumLat / coords.length; + lon = sumLon / coords.length; + } + + // Calculate BBox + const lats = coords.map((c) => c[1]); + const lons = coords.map((c) => c[0]); + bbox = { + minLat: Math.min(...lats), + maxLat: Math.max(...lats), + minLon: Math.min(...lons), + maxLon: Math.max(...lons), + }; } } else if (targetType === 'relation' && mainElement.members) { // Find all nodes that are part of this relation (directly or via ways) @@ -245,23 +262,37 @@ out center; }); if (allNodes.length > 0) { - const sumLat = allNodes.reduce((sum, n) => sum + n.lat, 0); - const sumLon = allNodes.reduce((sum, n) => sum + n.lon, 0); - lat = sumLat / allNodes.length; - lon = sumLon / allNodes.length; + // Only override lat/lon if we haven't switched to a specific display node + if (displayElement === mainElement) { + const sumLat = allNodes.reduce((sum, n) => sum + n.lat, 0); + const sumLon = allNodes.reduce((sum, n) => sum + n.lon, 0); + lat = sumLat / allNodes.length; + lon = sumLon / allNodes.length; + } + + // Calculate BBox + const lats = allNodes.map((n) => n.lat); + const lons = allNodes.map((n) => n.lon); + bbox = { + minLat: Math.min(...lats), + maxLat: Math.max(...lats), + minLon: Math.min(...lons), + maxLon: Math.max(...lons), + }; } } - const tags = mainElement.tags || {}; + const tags = displayElement.tags || {}; const type = getPlaceType(tags) || 'Point of Interest'; return { title: getLocalizedName(tags), lat, lon, + bbox, url: tags.website, - osmId: String(mainElement.id), - osmType: mainElement.type, + osmId: String(displayElement.id), + osmType: displayElement.type, osmTags: tags, description: tags.description, source: 'osm', diff --git a/tests/unit/services/osm-test.js b/tests/unit/services/osm-test.js index 74a8e64..52b109b 100644 --- a/tests/unit/services/osm-test.js +++ b/tests/unit/services/osm-test.js @@ -98,6 +98,41 @@ module('Unit | Service | osm', function (hooks) { assert.strictEqual(result.osmType, 'node'); }); + test('normalizeOsmApiData calculates bbox for relations', function (assert) { + let service = this.owner.lookup('service:osm'); + const elements = [ + { + id: 789, + type: 'relation', + members: [ + { type: 'node', ref: 1, role: 'label' }, + { type: 'node', ref: 2, role: 'border' }, + { type: 'node', ref: 3, role: 'border' }, + ], + tags: { name: 'Test Relation' }, + }, + { id: 1, type: 'node', lat: 10, lon: 10, tags: { name: 'Label' } }, + { id: 2, type: 'node', lat: 0, lon: 0 }, + { id: 3, type: 'node', lat: 20, lon: 20 }, + ]; + + const result = service.normalizeOsmApiData(elements, 789, 'relation'); + + // Should prioritize admin centre for ID/Title/Center + assert.strictEqual(result.title, 'Label'); + assert.strictEqual(result.lat, 10); + assert.strictEqual(result.lon, 10); + assert.strictEqual(result.osmId, '1'); + assert.strictEqual(result.osmType, 'node'); + + // BUT should calculate BBox from ALL members (0,0 to 20,20) + assert.ok(result.bbox, 'BBox should be present'); + assert.strictEqual(result.bbox.minLat, 0); + assert.strictEqual(result.bbox.minLon, 0); + assert.strictEqual(result.bbox.maxLat, 20); + assert.strictEqual(result.bbox.maxLon, 20); + }); + test('normalizeOsmApiData calculates centroid for relations with member ways', function (assert) { let service = this.owner.lookup('service:osm'); /*