Skip to main content

Search Category

|

This example demonstrates how to search for landmarks filtered by specific categories (e.g., restaurants, hotels, gas stations).

Live Demo

Overview

This example shows how to:

  • Search for landmarks based on specific categories
  • Display a multi-select category filter UI
  • Combine text search with category filtering
  • Highlight and navigate to selected search results

Code Implementation

Imports

First, import the required modules from the SDK:

import {
GemKit,
GemMap,
Coordinates,
PositionService,
Landmark,
LandmarkCategory,
SearchPreferences,
SearchService,
GemError,
HighlightRenderSettings,
HighlightOptions,
AddressField,
GemIcon,
} from '@magiclane/maps-sdk';

SDK Initialization

Initialize the SDK and create a map view (UI omitted):

index.ts
let map: GemMap | null = null;
let selectedCategories: LandmarkCategory[] = [];
let searchResults: Landmark[] = [];
let categories: LandmarkCategory[] = [];

window.addEventListener('DOMContentLoaded', async () => {
const gemKit = await GemKit.initialize(GEMKIT_TOKEN);
await PositionService.instance;

const viewId = 2;
const wrapper = gemKit.createView(viewId, (gemMap: GemMap) => {
map = gemMap;
});
// Fetch categories from SDK
try {
categories = (window as any).GenericCategories?.categories || [];
} catch (error) {
categories = [];
}
});

Create Category Selection Modal

Build a modal with category checkboxes and text input:

index.ts
function openSearchModal() {
if (searchModal) {
searchModal.remove();
searchModal = null;
}

searchModal = document.createElement('div');
searchModal.style.cssText = `
position: fixed; top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0,0,0,0.25); z-index: 3000; display: flex;
align-items: center; justify-content: center;
`;

const modalContent = document.createElement('div');
modalContent.style.cssText = `
background: #fff; border-radius: 12px; box-shadow: 0 4px 24px rgba(0,0,0,0.2);
padding: 32px 24px; min-width: 400px; max-width: 90vw; max-height: 90vh; overflow-y: auto;
`;

// Title
const title = document.createElement('h2');
title.textContent = 'Search Category';
title.style.cssText = 'margin-top: 0; color: #673ab7;';
modalContent.appendChild(title);

// Text input
const textInput = document.createElement('input');
textInput.type = 'text';
textInput.placeholder = 'Enter text';
textInput.style.cssText = `
width: 100%; padding: 8px; font-size: 1em; margin-bottom: 16px;
border: 1px solid #ddd; border-radius: 6px;
`;
modalContent.appendChild(textInput);

// Category list
const categoryList = document.createElement('div');
categoryList.style.cssText = 'margin-bottom: 16px;';

categories.forEach((cat) => {
const item = document.createElement('div');
item.textContent = cat.name;
const isSelected = selectedCategories.includes(cat);
item.style.cssText = `
padding: 8px 12px; margin-bottom: 4px; border-radius: 6px; cursor: pointer;
background: ${isSelected ? '#e0e0e0' : '#fafafa'};
font-weight: 500; color: #333; border: 1px solid #ddd;
transition: background-color 0.2s;
`;
item.onclick = () => {
if (selectedCategories.includes(cat)) {
selectedCategories = selectedCategories.filter(c => c !== cat);
} else {
selectedCategories.push(cat);
}
openSearchModal();
};
categoryList.appendChild(item);
});
modalContent.appendChild(categoryList);

// Search button
const searchBtn = document.createElement('button');
searchBtn.textContent = 'Search';
searchBtn.style.cssText = `
padding: 10px 24px; background: #673ab7; color: #fff;
border: none; border-radius: 8px; font-size: 1em;
font-weight: 500; cursor: pointer;
`;
searchBtn.onclick = () => {
performSearch(textInput.value);
};
modalContent.appendChild(searchBtn);

// Results list (if available)
if (searchResults.length > 0) {
const resultsList = document.createElement('div');
resultsList.style.cssText = 'margin-top: 24px; max-height: 300px; overflow-y: auto;';

searchResults.forEach((lmk) => {
const resultItem = document.createElement('div');
resultItem.style.cssText = `
padding: 12px; border-bottom: 1px solid #eee; cursor: pointer;
transition: background-color 0.2s;
`;
resultItem.innerHTML = `
<strong>${lmk.name}</strong><br>
<span style="color:#666;">${getFormattedDistance(lmk)} ${getAddress(lmk)}</span>
`;
resultItem.onclick = () => {
selectSearchResult(lmk);
};
resultItem.addEventListener('mouseover', () => {
resultItem.style.backgroundColor = '#f5f5f5';
});
resultItem.addEventListener('mouseout', () => {
resultItem.style.backgroundColor = 'transparent';
});
resultsList.appendChild(resultItem);
});
modalContent.appendChild(resultsList);
}

// Close button
const closeBtn = document.createElement('button');
closeBtn.textContent = 'Close';
closeBtn.style.cssText = `
margin-top: 24px; padding: 8px 20px; background: #eee; color: #333;
border: none; border-radius: 8px; font-size: 1em;
font-weight: 500; cursor: pointer;
`;
closeBtn.onclick = () => {
searchModal?.remove();
searchModal = null;
searchResults = [];
};
modalContent.appendChild(closeBtn);

searchModal.appendChild(modalContent);
document.body.appendChild(searchModal);
}

Perform Category-filtered Search (SDK-only)

Execute search using selected categories and preferences (UI omitted):

index.ts
function performSearch(text: string) {
if (!map) return;

// Set up search preferences
const preferences = SearchPreferences.create({
maxMatches: 40,
allowFuzzyResults: true,
searchMapPOIs: true,
searchAddresses: false,
});

// Add selected categories to preferences
selectedCategories.forEach(cat => {
preferences.landmarks?.addStoreCategoryId(cat.landmarkStoreId, cat.id);
});

// Execute SDK search around current view center (coordinate acquisition omitted)
SearchService.searchAroundPosition({
position: /* center coordinates */ undefined as any,
preferences,
textFilter: text,
onCompleteCallback: (err: GemError, results: Landmark[]) => {
if (err !== GemError.success) {
searchResults = [];
return;
}
searchResults = results;
}
});
}

Display Search Results

Format distance and address information:

index.ts
function getFormattedDistance(landmark: Landmark): string {
try {
const dist = landmark.extraInfo?.getByKey?.('gmSearchResultDistance') || 0;
const km = (dist / 1000).toFixed(1);
return `${km} km`;
} catch {
return '';
}
}

function getAddress(landmark: Landmark): string {
try {
const addressInfo = landmark.address || {};
const street = addressInfo.getField?.(AddressField.streetName) || '';
const city = addressInfo.getField?.(AddressField.city) || '';

if (!street && !city) return '';
return [street, city].filter(Boolean).join(', ');
} catch {
return '';
}
}

Select and Highlight Result

Handle result selection and map navigation:

index.ts
function selectSearchResult(landmark: Landmark) {
if (!map) return;
// Highlight the landmark
try {
const renderSettings = new HighlightRenderSettings({
options: new Set([HighlightOptions.showLandmark])
});
// Ensure icon
try { landmark.setImageFromIcon(GemIcon.searchResultsPin); } catch(e){}

map.activateHighlight([landmark], { renderSettings });
} catch {
map.activateHighlight([landmark]);
}

// Center map on landmark
if (landmark.coordinates) {
map.centerOnCoordinates(landmark.coordinates, { zoomLevel: 70 });
}
showMessage(`Selected: ${landmark.name}`);

// On mobile-ish layouts, maybe close sidebar. For desktop, keep it open.
if (window.innerWidth < 600) toggleSidebar(false);
}

Utility Functions

Display temporary messages:

index.ts
function showMessage(message: string, duration = 3000) {
let msgDiv = document.getElementById('status-msg');
if (!msgDiv) {
msgDiv = document.createElement('div');
msgDiv.id = 'status-msg';
msgDiv.style.cssText = `
position: fixed; top: 20px; left: 50%; transform: translateX(-50%);
background: #333; color: #fff; padding: 12px 20px; border-radius: 8px;
z-index: 2000; font-size: 1em;
`;
document.body.appendChild(msgDiv);
}
msgDiv.textContent = message;
setTimeout(() => {
msgDiv.textContent = '';
}, duration);
}

Key Features

Category Filtering

The example uses LandmarkCategory objects to filter search results:

Category Selection:

  • Display all available categories from GenericCategories.categories
  • Allow multiple category selection
  • Visual feedback for selected categories

Adding Categories to Search:

selectedCategories.forEach(cat => {
preferences.landmarks.addStoreCategoryId(cat.landmarkStoreId, cat.id);
});

Search Preferences

Configure search behavior:

Parameters:

  • maxMatches: 40 - Limit results to 40 landmarks
  • allowFuzzyResults: false - Exact matching only
  • searchMapPOIs: true - Search points of interest
  • searchAddresses: false - Exclude address results

Text Filter

Combine category filtering with text search:

SearchService.searchAroundPosition({
position: coords,
preferences: preferences,
textFilter: text,
onCompleteCallback: callback
});

The textFilter parameter allows searching within the selected categories.

Map Center Coordinates

Get the center point of the current map view:

const x = container.offsetWidth / 2;
const y = container.offsetHeight / 2;
const coords = map.transformScreenToWgs({
x: Math.floor(x),
y: Math.floor(y)
});

This ensures search results are relevant to the visible area.

Multi-select UI

The category list supports multiple selections:

  • Click to toggle category selection
  • Visual indicator (gray background) for selected items
  • State persists when modal reopens with results

Implementation Details

  • Category State: Maintain selectedCategories array across modal reopens
  • Dynamic UI: Rebuild modal after each search to show results
  • Error Handling: Show "No results found" message on search failure
  • Result Display: Show distance and address for each landmark
  • Map Integration: Highlight and center map on selected result

Use Cases

  • Restaurant Search: Filter by "Restaurants" category
  • Hotel Booking: Search hotels in a specific area
  • Gas Stations: Find nearby fuel stations
  • POI Discovery: Browse landmarks by type (museums, parks, etc.)
  • Trip Planning: Combine categories (hotels + restaurants)

Next Steps