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 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 {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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],
|
||||||
|
]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user