Add map markers for search results

This commit is contained in:
2026-03-21 16:52:37 +04:00
parent 9183e3c366
commit 5b37894821
5 changed files with 119 additions and 11 deletions

View File

@@ -16,7 +16,7 @@ import Feature from 'ol/Feature.js';
import GeoJSON from 'ol/format/GeoJSON.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, Icon } from 'ol/style.js';
import { apply } from 'ol-mapbox-style'; import { apply } from 'ol-mapbox-style';
export default class MapComponent extends Component { export default class MapComponent extends Component {
@@ -28,6 +28,7 @@ export default class MapComponent extends Component {
mapInstance; mapInstance;
bookmarkSource; bookmarkSource;
searchResultsSource;
selectedShapeSource; selectedShapeSource;
searchOverlay; searchOverlay;
searchOverlayElement; searchOverlayElement;
@@ -110,6 +111,47 @@ export default class MapComponent extends Component {
zIndex: 10, // Ensure it sits above the map tiles zIndex: 10, // Ensure it sits above the map tiles
}); });
// Create a vector source and layer for search results
this.searchResultsSource = new VectorSource();
let cachedSearchResultSvgUrl = null;
const searchResultStyle = (feature) => {
if (!cachedSearchResultSvgUrl) {
const markerColor =
getComputedStyle(document.documentElement)
.getPropertyValue('--marker-color-primary')
.trim() || '#ea4335';
const svg = `
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-4 -4 32 40" width="40" height="50">
<defs>
<filter id="shadow" x="-50%" y="-50%" width="200%" height="200%">
<feDropShadow dx="0" dy="2" stdDeviation="1.5" flood-color="black" flood-opacity="0.3"/>
</filter>
</defs>
<path d="M12 2C6.5 2 2 6.5 2 12C2 17.5 12 24 12 24C12 24 22 17.5 22 12C22 6.5 17.5 2 12 2Z" fill="white" filter="url(#shadow)"/>
<circle cx="12" cy="12" r="8" fill="${markerColor}"/>
</svg>
`;
cachedSearchResultSvgUrl =
'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(svg.trim());
}
return new Style({
image: new Icon({
src: cachedSearchResultSvgUrl,
anchor: [0.5, 0.65],
scale: 1,
}),
});
};
const searchResultLayer = new VectorLayer({
source: this.searchResultsSource,
style: searchResultStyle,
zIndex: 11, // Above bookmarks (10)
});
// Default view settings // Default view settings
let center = [14.21683569, 27.060114248]; let center = [14.21683569, 27.060114248];
let zoom = 2.661; let zoom = 2.661;
@@ -143,7 +185,7 @@ export default class MapComponent extends Component {
this.mapInstance = new Map({ this.mapInstance = new Map({
target: element, target: element,
layers: [openfreemap, selectedShapeLayer, bookmarkLayer], layers: [openfreemap, selectedShapeLayer, searchResultLayer, bookmarkLayer],
view: view, view: view,
controls: defaultControls({ controls: defaultControls({
zoom: true, zoom: true,
@@ -178,7 +220,7 @@ export default class MapComponent extends Component {
const pinIcon = document.createElement('div'); const pinIcon = document.createElement('div');
pinIcon.className = 'selected-pin'; pinIcon.className = 'selected-pin';
// Simple SVG for Map Pin // Simple SVG for Map Pin
pinIcon.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"></path><circle cx="12" cy="10" r="3" style="fill: #b31412; stroke: none;"></circle></svg>`; pinIcon.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"></path><circle cx="12" cy="10" r="3" style="fill: var(--marker-color-dark); stroke: none;"></circle></svg>`;
const pinShadow = document.createElement('div'); const pinShadow = document.createElement('div');
pinShadow.className = 'selected-pin-shadow'; pinShadow.className = 'selected-pin-shadow';
@@ -464,6 +506,36 @@ export default class MapComponent extends Component {
); );
}); });
updateSearchResults = modifier(() => {
if (!this.searchResultsSource) return;
this.searchResultsSource.clear();
const results = this.mapUi.searchResults;
if (!results || results.length === 0) return;
const features = [];
results.forEach((place) => {
// Don't render if it's already a bookmark to avoid clutter/overlap
// Although user might want to see it's a search result...
// Let's render it, but z-index handles overlap (bookmarks are higher)
if (place.lat && place.lon) {
const feature = new Feature({
geometry: new Point(fromLonLat([place.lon, place.lat])),
name: place.title,
id: place.id,
isSearchResult: true,
originalPlace: place,
});
features.push(feature);
}
});
if (features.length > 0) {
this.searchResultsSource.addFeatures(features);
}
});
// Track the selected place from the UI Service (Router -> Map) // Track the selected place from the UI Service (Router -> Map)
updateSelectedPin = modifier(() => { updateSelectedPin = modifier(() => {
const selected = this.mapUi.selectedPlace; const selected = this.mapUi.selectedPlace;
@@ -946,6 +1018,7 @@ export default class MapComponent extends Component {
hitTolerance: 10, hitTolerance: 10,
}); });
let clickedBookmark = null; let clickedBookmark = null;
let clickedSearchResult = null;
let selectedFeatureName = null; let selectedFeatureName = null;
if (features && features.length > 0) { if (features && features.length > 0) {
@@ -954,8 +1027,12 @@ export default class MapComponent extends Component {
console.debug(f); console.debug(f);
} }
const bookmarkFeature = features.find((f) => f.get('isBookmark')); const bookmarkFeature = features.find((f) => f.get('isBookmark'));
const searchResultFeature = features.find((f) => f.get('isSearchResult'));
if (bookmarkFeature) { if (bookmarkFeature) {
clickedBookmark = bookmarkFeature.get('originalPlace'); clickedBookmark = bookmarkFeature.get('originalPlace');
} else if (searchResultFeature) {
clickedSearchResult = searchResultFeature.get('originalPlace');
} }
// Also get visual props for standard map click logic later // Also get visual props for standard map click logic later
const props = features[0].getProperties(); const props = features[0].getProperties();
@@ -966,14 +1043,15 @@ export default class MapComponent extends Component {
// Special handling when sidebar is OPEN // Special handling when sidebar is OPEN
if (this.args.isSidebarOpen) { if (this.args.isSidebarOpen) {
// If it's a bookmark, we allow "switching" to it even if sidebar is open // If it's a bookmark or search result, we allow "switching" to it even if sidebar is open
if (clickedBookmark) { const targetPlace = clickedBookmark || clickedSearchResult;
if (targetPlace) {
console.debug( console.debug(
'Clicked bookmark while sidebar open (switching):', 'Clicked feature while sidebar open (switching):',
clickedBookmark targetPlace
); );
this.mapUi.preventNextZoom = true; this.mapUi.preventNextZoom = true;
this.router.transitionTo('place', clickedBookmark); this.router.transitionTo('place', targetPlace);
return; return;
} }
@@ -992,6 +1070,13 @@ export default class MapComponent extends Component {
return; return;
} }
if (clickedSearchResult) {
console.debug('Clicked search result:', clickedSearchResult);
this.mapUi.preventNextZoom = true;
this.router.transitionTo('place', clickedSearchResult);
return;
}
// Require Zoom >= 17 for generic map searches // Require Zoom >= 17 for generic map searches
// This prevents accidental searches when interacting with the map at a high level // This prevents accidental searches when interacting with the map at a high level
const currentZoom = this.mapInstance.getView().getZoom(); const currentZoom = this.mapInstance.getView().getZoom();
@@ -1041,6 +1126,7 @@ export default class MapComponent extends Component {
{{this.setupMap}} {{this.setupMap}}
{{this.updateInteractions}} {{this.updateInteractions}}
{{this.updateBookmarks}} {{this.updateBookmarks}}
{{this.updateSearchResults}}
{{this.updateSelectedPin}} {{this.updateSelectedPin}}
{{this.syncPulse}} {{this.syncPulse}}
{{this.syncCreationMode}} {{this.syncCreationMode}}

10
app/routes/index.js Normal file
View File

@@ -0,0 +1,10 @@
import Route from '@ember/routing/route';
import { service } from '@ember/service';
export default class IndexRoute extends Route {
@service mapUi;
activate() {
this.mapUi.clearSearchResults();
}
}

View File

@@ -169,6 +169,7 @@ export default class SearchRoute extends Route {
super.setupController(controller, model); super.setupController(controller, model);
// Ensure pulse is stopped if we reach here // Ensure pulse is stopped if we reach here
this.mapUi.stopSearch(); this.mapUi.stopSearch();
this.mapUi.setSearchResults(model);
} }
@action @action

View File

@@ -12,6 +12,7 @@ export default class MapUiService extends Service {
@tracked searchBoxHasFocus = false; @tracked searchBoxHasFocus = false;
@tracked selectionOptions = {}; @tracked selectionOptions = {};
@tracked preventNextZoom = false; @tracked preventNextZoom = false;
@tracked searchResults = [];
selectPlace(place, options = {}) { selectPlace(place, options = {}) {
this.selectedPlace = place; this.selectedPlace = place;
@@ -24,6 +25,14 @@ export default class MapUiService extends Service {
this.preventNextZoom = false; this.preventNextZoom = false;
} }
setSearchResults(results) {
this.searchResults = results || [];
}
clearSearchResults() {
this.searchResults = [];
}
startSearch() { startSearch() {
this.isSearching = true; this.isSearching = true;
this.isCreating = false; this.isCreating = false;

View File

@@ -6,6 +6,8 @@
--sidebar-width: 350px; --sidebar-width: 350px;
--link-color: #2a7fff; --link-color: #2a7fff;
--link-color-visited: #6a4fbf; --link-color-visited: #6a4fbf;
--marker-color-primary: #ea4335;
--marker-color-dark: #b31412;
} }
html, html,
@@ -872,15 +874,15 @@ span.icon {
.selected-pin { .selected-pin {
width: 40px; width: 40px;
height: 40px; height: 40px;
color: #ea4335; /* Google Red */ color: var(--marker-color-primary);
filter: drop-shadow(0 4px 6px rgb(0 0 0 / 30%)); filter: drop-shadow(0 4px 6px rgb(0 0 0 / 30%));
} }
.selected-pin svg { .selected-pin svg {
width: 100%; width: 100%;
height: 100%; height: 100%;
fill: #ea4335; fill: var(--marker-color-primary);
stroke: #b31412; /* Darker red stroke */ stroke: var(--marker-color-dark);
stroke-width: 1; stroke-width: 1;
} }