Draw outlines/areas for ways and relations on map
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user