Skip to content
Merged
1 change: 1 addition & 0 deletions map/AGENTS.md
1 change: 1 addition & 0 deletions map/src/infoblock/components/wpt/WptTagsProvider.js
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ export const CITY = WEB_PREFIX + 'city';
export const ADDRESS_1 = WEB_PREFIX + 'address1';
export const ADDRESS_2 = WEB_PREFIX + 'address2';
export const MATCHED_OBJECTS = WEB_PREFIX + 'matched_objects';
export const BBOX_LAT_LON = WEB_PREFIX + 'bbox_lat_lon';
export const CATEGORY_KEY_NAME = WEB_PREFIX + 'keyName';
export const ICON_KEY_NAME = WEB_PREFIX + 'iconKeyName';
export const TYPE_OSM_TAG = WEB_PREFIX + 'typeOsmTag';
Expand Down
166 changes: 166 additions & 0 deletions map/src/manager/SpatialSearchMatchedObjects.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
import {
BBOX_LAT_LON,
CATEGORY_NAME,
CATEGORY_TYPE,
MATCHED_OBJECTS,
POI_ID,
POI_NAME,
} from '../infoblock/components/wpt/WptTagsProvider';

export const MATCHED_OBJECT_TYPE_AMENITY = 'Amenity';
export const MATCHED_OBJECT_TYPE_CITY = 'City';
export const MATCHED_OBJECT_TYPE_STREET = 'Street';

const DEFAULT_MATCHED_AMENITY_CATEGORY_TYPE = 'POI';

export function getAdditionalMatchedAmenityObjects(matchedObjects) {
return matchedObjects?.length > 1
? matchedObjects.slice(1).filter((obj) => obj?.type === MATCHED_OBJECT_TYPE_AMENITY)
: [];
}

export function getFirstMatchedPoiTypeLocationObject(matchedObjects) {
return (
matchedObjects?.find(
(obj) =>
obj?.type === MATCHED_OBJECT_TYPE_CITY ||
obj?.type === MATCHED_OBJECT_TYPE_STREET ||
obj?.type === MATCHED_OBJECT_TYPE_AMENITY
) ?? null
);
}

export function getMatchedAmenityProperties(obj, defaultCategoryType = DEFAULT_MATCHED_AMENITY_CATEGORY_TYPE) {
return {
...obj,
[CATEGORY_TYPE]: obj[CATEGORY_TYPE] ?? defaultCategoryType,
[POI_NAME]: obj[POI_NAME] ?? obj.name ?? '',
name: obj.name ?? obj[POI_NAME],
};
}

export function getMatchedObjectName(obj) {
return obj?.name ?? obj?.[CATEGORY_NAME] ?? obj?.[POI_NAME];
}

export function hasValidMatchedObjectCoords(obj) {
return Number.isFinite(obj?.lat) && Number.isFinite(obj?.lon);
}

export function createSearchMatchedObjectActions({
item,
t,
ctx,
navigate,
recentSaver,
setShowMatched,
formatSearchResultProperties,
navigateToPoi,
objectSearchType,
poiObjectsKey,
poiTypeCategory,
}) {
const matchedObjects = item.properties?.[MATCHED_OBJECTS] ?? [];
const isPoiTypeResult = item.properties?.[CATEGORY_TYPE] === poiTypeCategory;
const matchedAmenityObjects = isPoiTypeResult ? [] : getAdditionalMatchedAmenityObjects(matchedObjects);
const matchedPoiTypeLocationObject = isPoiTypeResult ? getFirstMatchedPoiTypeLocationObject(matchedObjects) : null;

function openMatchedObject(obj) {
if (!hasValidMatchedObjectCoords(obj)) {
return;
}
ctx.setZoomToCoords({ lat: obj.lat, lon: obj.lon, bbox: obj[BBOX_LAT_LON] });
setShowMatched(false);
}

function openMatchedAmenity(obj) {
if (!hasValidMatchedObjectCoords(obj)) {
return;
}

const options = getMatchedAmenityProperties(obj);
const id = obj[POI_ID] ?? `${obj.lat},${obj.lon}`;
const poi = {
key: id,
options,
latlng: { lat: obj.lat, lng: obj.lon },
};

ctx.setCurrentObjectType(objectSearchType);
ctx.setSelectedPoiObj({ ...poi });
ctx.setSelectedWpt({ poi, id });
recentSaver(poiObjectsKey, poi);
ctx.setMoveToMapObj({
type: 'Feature',
geometry: { type: 'Point', coordinates: [obj.lon, obj.lat] },
properties: options,
});
navigateToPoi({ poi }, navigate);
}

function getMatchedAmenityName(obj) {
return formatSearchResultProperties(getMatchedAmenityProperties(obj), t).name;
}

function moveToMatchedPoiTypeLocation() {
openMatchedObject(matchedPoiTypeLocationObject);
}

const matchedNameObjects = [
...matchedAmenityObjects.map((obj, index) =>
createMatchedNameObject({
obj,
index,
name: getMatchedAmenityName(obj),
openMatchedObject: openMatchedAmenity,
})
),
...(matchedPoiTypeLocationObject
? [
createMatchedNameObject({
obj: matchedPoiTypeLocationObject,
index: matchedAmenityObjects.length,
name: getMatchedObjectName(matchedPoiTypeLocationObject),
openMatchedObject,
}),
]
: []),
].filter(({ name }) => name);

const matchedDialogObjects = matchedObjects.map((obj, index) => ({
key: getMatchedObjectKey(obj, index),
obj,
onClick: () => openMatchedObject(obj),
}));

return {
matchedObjects,
matchedNameObjects,
matchedDialogObjects,
moveToMatchedPoiTypeLocation,
};
}

function createMatchedNameObject({ obj, index, name, openMatchedObject }) {
const onClick = (event) => {
event.stopPropagation();
openMatchedObject(obj);
};

return {
key: getMatchedObjectKey(obj, index),
obj,
name,
onClick,
onKeyDown: (event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
onClick(event);
}
},
};
}

function getMatchedObjectKey(obj, index = 0) {
return obj[POI_ID] ?? `${obj.type}-${obj.lat}-${obj.lon}-${index}`;
}
125 changes: 119 additions & 6 deletions map/src/map/layers/SearchLayer.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,12 @@ import { useMap } from 'react-leaflet';
import { getPoiIcon } from './PoiLayer';
import L from 'leaflet';
import {
BBOX_LAT_LON,
CATEGORY_NAME,
CATEGORY_TYPE,
FINAL_POI_ICON_NAME,
ICON_KEY_NAME,
MATCHED_OBJECTS,
POI_ICON_NAME,
POI_ID,
POI_NAME,
Expand Down Expand Up @@ -50,6 +52,12 @@ import { hideMarkersNearPin } from '../util/MarkerSelectionService';
import { POI_OBJECTS_KEY, useRecentDataSaver } from '../../util/hooks/menu/useRecentDataSaver';
import { useNavigate } from 'react-router-dom';
import { getCurrentTimeParams } from '../../util/Utils';
import { fitBoundsOptions } from '../../manager/track/TracksManager';
import {
getAdditionalMatchedAmenityObjects,
getMatchedAmenityProperties,
hasValidMatchedObjectCoords,
} from '../../manager/SpatialSearchMatchedObjects';

export const SEARCH_TYPE_CATEGORY = 'category';

Expand Down Expand Up @@ -109,6 +117,31 @@ export function buildFavGroupMap(favoriteFeatures) {
return result.size > 0 ? result : null;
}

function getBboxLatLngBounds(bbox) {
if (!bbox) return null;
const top = Number(bbox.top);
const left = Number(bbox.left);
const bottom = Number(bbox.bottom);
const right = Number(bbox.right);
if (![top, left, bottom, right].every(Number.isFinite)) return null;

return L.latLngBounds([
[bottom, left],
[top, right],
]);
}

function fitBboxIfValid({ map, mtx, bbox }) {
const bounds = getBboxLatLngBounds(bbox);
if (!map || !bounds?.isValid()) {
return false;
}

map.fitBounds(bounds, fitBoundsOptions(mtx));

return true;
}

export default function SearchLayer() {
const ctx = useContext(AppContext);
const mtx = useContext(MapContext);
Expand Down Expand Up @@ -138,7 +171,9 @@ export default function SearchLayer() {

useEffect(() => {
if (ctx.zoomToCoords) {
panToIfNeeded({ map, latlng: { lat: ctx.zoomToCoords.lat, lon: ctx.zoomToCoords.lon }, ctx });
if (!fitBboxIfValid({ map, mtx, bbox: ctx.zoomToCoords.bbox })) {
panToIfNeeded({ map, latlng: { lat: ctx.zoomToCoords.lat, lon: ctx.zoomToCoords.lon }, ctx });
}
ctx.setZoomToCoords(null);
}
}, [ctx.zoomToCoords]);
Expand All @@ -149,7 +184,7 @@ export default function SearchLayer() {
if (oldPoiLayer) {
map.removeLayer(oldPoiLayer);
}
if (ctx.searchQuery) {
if (ctx.searchQuery?.query || ctx.searchQuery?.type) {
ctx.setShowPoiCategories([]);
if (ctx.searchQuery.type) {
searchByCategory(ctx.searchQuery);
Expand All @@ -168,7 +203,9 @@ export default function SearchLayer() {
// When favorites change (rename, edit, delete), refresh the favorites part of search results
useEffect(() => {
const query = ctx.searchQuery?.query;
if (!query || ctx.searchQuery?.type || !ctx.searchResult) return;
if (!query || ctx.searchQuery?.type || !ctx.searchResult) {
return;
}

const favoriteFeatures = searchFavoriteFeatures({
favorites: ctx.favorites,
Expand Down Expand Up @@ -236,7 +273,9 @@ export default function SearchLayer() {
pushMapView({ map, mtx, key: MAP_VIEW_SEARCH_RESULT });
}
const [lng, lat] = ctx.moveToMapObj.geometry.coordinates;
panToIfNeeded({ map, latlng: { lat, lng }, ctx });
if (!fitBboxIfValid({ map, mtx, bbox: ctx.moveToMapObj.properties?.[BBOX_LAT_LON] })) {
panToIfNeeded({ map, latlng: { lat, lng }, ctx });
}
ctx.setMoveToMapObj(null);
}
}, [ctx.moveToMapObj]);
Expand Down Expand Up @@ -347,15 +386,21 @@ export default function SearchLayer() {

async function createSearchLayer({ objList }) {
const visibleObjList = filterByVisibleBounds(objList, getVisibleBboxInfo(ctx, map)?.bounds);
const innerCache = await createPoiCache({ poiList: visibleObjList, poiIconCache: ctx.poiIconCache });
const searchMarkerFeatures = createSearchMarkerFeatures(visibleObjList);
const innerCache = await createPoiCache({
poiList: searchMarkerFeatures,
poiIconCache: ctx.poiIconCache,
});
updatePoiCache(ctx, innerCache);

const center = map.getCenter();
const zoom = map.getZoom();
const latitude = center.lat;
// FAVORITE and GPX_TRACK are user objects rendered by their own layers — skip map markers for them.
const USER_OBJECT_TYPES = new Set([searchTypeMap.FAVORITE, searchTypeMap.GPX_TRACK]);
const mapMarkerFeatures = visibleObjList.filter((f) => !USER_OBJECT_TYPES.has(f.properties?.[CATEGORY_TYPE]));
const mapMarkerFeatures = searchMarkerFeatures.filter(
(f) => !USER_OBJECT_TYPES.has(f.properties?.[CATEGORY_TYPE])
);

const { mainMarkers, secondaryMarkers } = clusterMarkers({
places: mapMarkerFeatures,
Expand Down Expand Up @@ -493,6 +538,74 @@ function filterByVisibleLevel(features, spatialSearch, visibleLevel) {
return (features ?? []).filter((f) => (f?.properties?.[WEB_VISIBLE_LEVEL] ?? 0) <= visibleLevel);
}

function createSearchMarkerFeatures(features) {
const markerFeatures = (features ?? []).map((feature) => ({
...feature,
properties: { ...(feature.properties ?? {}) },
}));
const featureByKey = new Map();

markerFeatures.forEach((feature) => {
const key = getFeatureKey(feature);
if (key) {
featureByKey.set(key, feature);
}
});

markerFeatures.forEach((feature) => {
const resultId = getObjIdSearch(feature);
getAdditionalMatchedAmenityObjects(feature?.properties?.[MATCHED_OBJECTS]).forEach((obj) => {
if (!hasValidMatchedObjectCoords(obj)) {
return;
}

const key = getMatchedAmenityKey(obj);
const existing = featureByKey.get(key);
if (existing) {
addRelatedResultId(existing.properties, resultId);
return;
}

const matchedFeature = {
type: 'Feature',
geometry: {
type: 'Point',
coordinates: [obj.lon, obj.lat],
},
properties: {
...getMatchedAmenityProperties(obj, searchTypeMap.POI),
},
};
addRelatedResultId(matchedFeature.properties, resultId);
featureByKey.set(key, matchedFeature);
markerFeatures.push(matchedFeature);
});
});

return markerFeatures;
}

function getFeatureKey(feature) {
const coord = feature.geometry.coordinates;
const name = feature.properties?.[POI_NAME] ?? feature.properties?.name ?? feature.properties?.[CATEGORY_NAME];
return feature?.properties?.[POI_ID] ?? formatSearchMarkerKey(coord[1], coord[0], name);
}

function getMatchedAmenityKey(obj) {
return obj[POI_ID] ?? formatSearchMarkerKey(obj.lat, obj.lon, obj[POI_NAME] ?? obj.name);
}

function formatSearchMarkerKey(lat, lon, name) {
return `${lat.toFixed(6)},${lon.toFixed(6)}:${name ?? ''}`;
}

function addRelatedResultId(properties, resultId) {
if (resultId == null) return;
const ids = new Set(properties.relatedResultIds ?? []);
ids.add(resultId);
properties.relatedResultIds = Array.from(ids);
}

function filterByVisibleBounds(features, bounds) {
if (!bounds) return features ?? [];

Expand Down
Loading