Draw outlines/areas for ways and relations on map

This commit is contained in:
2026-02-24 11:22:57 +04:00
parent 1926e2b20c
commit d827fe263b
5 changed files with 298 additions and 3 deletions

View File

@@ -13,6 +13,7 @@ import LayerGroup from 'ol/layer/Group.js';
import VectorLayer from 'ol/layer/Vector.js'; import VectorLayer from 'ol/layer/Vector.js';
import VectorSource from 'ol/source/Vector.js'; import VectorSource from 'ol/source/Vector.js';
import Feature from 'ol/Feature.js'; import Feature from 'ol/Feature.js';
import GeoJSON from 'ol/format/GeoJSON.js';
import Point from 'ol/geom/Point.js'; import Point from 'ol/geom/Point.js';
import Geolocation from 'ol/Geolocation.js'; import Geolocation from 'ol/Geolocation.js';
import { Style, Circle, Fill, Stroke } from 'ol/style.js'; import { Style, Circle, Fill, Stroke } from 'ol/style.js';
@@ -27,6 +28,7 @@ export default class MapComponent extends Component {
mapInstance; mapInstance;
bookmarkSource; bookmarkSource;
selectedShapeSource;
searchOverlay; searchOverlay;
searchOverlayElement; searchOverlayElement;
selectedPinOverlay; selectedPinOverlay;
@@ -40,6 +42,22 @@ export default class MapComponent extends Component {
const openfreemap = new LayerGroup(); 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 // Create a vector source and layer for bookmarks
this.bookmarkSource = new VectorSource(); this.bookmarkSource = new VectorSource();
const bookmarkLayer = new VectorLayer({ const bookmarkLayer = new VectorLayer({
@@ -99,7 +117,7 @@ export default class MapComponent extends Component {
this.mapInstance = new Map({ this.mapInstance = new Map({
target: element, target: element,
layers: [openfreemap, bookmarkLayer], layers: [openfreemap, selectedShapeLayer, bookmarkLayer],
view: view, view: view,
controls: defaultControls({ controls: defaultControls({
zoom: true, zoom: true,
@@ -426,6 +444,11 @@ export default class MapComponent extends Component {
if (!this.selectedPinOverlay || !this.selectedPinElement) return; if (!this.selectedPinOverlay || !this.selectedPinElement) return;
// Clear any previous shape
if (this.selectedShapeSource) {
this.selectedShapeSource.clear();
}
if (selected && selected.lat && selected.lon) { if (selected && selected.lat && selected.lon) {
const coords = fromLonLat([selected.lon, selected.lat]); const coords = fromLonLat([selected.lon, selected.lat]);
this.selectedPinOverlay.setPosition(coords); this.selectedPinOverlay.setPosition(coords);
@@ -436,6 +459,18 @@ export default class MapComponent extends Component {
void this.selectedPinElement.offsetWidth; void this.selectedPinElement.offsetWidth;
this.selectedPinElement.classList.add('active'); 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) { if (selected.bbox) {
this.zoomToBbox(selected.bbox); this.zoomToBbox(selected.bbox);
} else { } else {

View File

@@ -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 // Notify the Map UI to show the pin
if (model) { if (model) {
this.mapUi.selectPlace(model); this.mapUi.selectPlace(model);

View File

@@ -218,6 +218,7 @@ out center;
let lat = displayElement.lat; let lat = displayElement.lat;
let lon = displayElement.lon; let lon = displayElement.lon;
let bbox = null; let bbox = null;
let geojson = null;
// If it's a way, calculate center from nodes // If it's a way, calculate center from nodes
if (targetType === 'way' && mainElement.nodes) { if (targetType === 'way' && mainElement.nodes) {
@@ -250,6 +251,25 @@ out center;
minLon: Math.min(...lons), minLon: Math.min(...lons),
maxLon: Math.max(...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) { } else if (targetType === 'relation' && mainElement.members) {
// Find all nodes that are part of this relation (directly or via ways) // 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) => { mainElement.members.forEach((member) => {
if (member.type === 'node') { if (member.type === 'node') {
const node = nodeMap.get(member.ref); const node = nodeMap.get(member.ref);
@@ -270,10 +292,17 @@ out center;
(el) => el.type === 'way' && el.id === member.ref (el) => el.type === 'way' && el.id === member.ref
); );
if (way && way.nodes) { if (way && way.nodes) {
const wayCoords = [];
way.nodes.forEach((nodeId) => { way.nodes.forEach((nodeId) => {
const node = nodeMap.get(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), maxLon: Math.max(...lons),
}; };
} }
if (segments.length > 0) {
geojson = {
type: 'MultiLineString',
coordinates: segments,
};
}
} }
const tags = displayElement.tags || {}; const tags = displayElement.tags || {};
@@ -307,6 +343,7 @@ out center;
lat, lat,
lon, lon,
bbox, bbox,
geojson,
url: tags.website, url: tags.website,
osmId: String(displayElement.id), osmId: String(displayElement.id),
osmType: displayElement.type, osmType: displayElement.type,

View File

@@ -1,5 +1,6 @@
import { module, test } from 'qunit'; import { module, test } from 'qunit';
import { setupTest } from 'marco/tests/helpers'; import { setupTest } from 'marco/tests/helpers';
import Service from '@ember/service';
module('Unit | Route | place', function (hooks) { module('Unit | Route | place', function (hooks) {
setupTest(hooks); setupTest(hooks);
@@ -8,4 +9,120 @@ module('Unit | Route | place', function (hooks) {
let route = this.owner.lookup('route:place'); let route = this.owner.lookup('route:place');
assert.ok(route); 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');
});
}); });

View File

@@ -166,4 +166,89 @@ module('Unit | Service | osm', function (hooks) {
assert.strictEqual(result.osmId, '999'); assert.strictEqual(result.osmId, '999');
assert.strictEqual(result.osmType, 'relation'); 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],
]);
});
}); });