-
Notifications
You must be signed in to change notification settings - Fork 109
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #779 from modos189/feature/search_api
Search API
Showing
11 changed files
with
1,051 additions
and
497 deletions.
There are no files selected for viewing
Large diffs are not rendered by default.
Oops, something went wrong.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,200 @@ | ||
/* global L -- eslint */ | ||
|
||
/** | ||
* Handles search-related hooks for the IITC.search module, adding various search result types. | ||
* | ||
* These functions supply default search results to the IITC search system by responding to `search` hooks with | ||
* data for portals, geographic coordinates, OpenStreetMap locations, and portal GUIDs. | ||
* | ||
* @namespace hooks | ||
* @memberof IITC.search | ||
*/ | ||
|
||
/** | ||
* Searches for portals by matching the query term against portal titles and adds matched results. | ||
* | ||
* @param {Object} query - The search query object. | ||
* @fires hook#search | ||
*/ | ||
window.addHook('search', (query) => { | ||
const term = query.term.toLowerCase(); | ||
|
||
for (const [guid, portal] of Object.entries(window.portals)) { | ||
const data = portal.options.data; | ||
if (!data.title) continue; | ||
|
||
if (data.title.toLowerCase().includes(term)) { | ||
window.search.addSearchResult(query, data, guid); | ||
} | ||
} | ||
}); | ||
|
||
/** | ||
* Searches for geographical coordinates formatted as latitude, longitude and adds the results. | ||
* Supports both decimal format (e.g., 51.5074, -0.1278) and DMS format (e.g., 50°31'03.8"N 7°59'05.3"E). | ||
* | ||
* @param {Object} query - The search query object. | ||
* @fires hook#search | ||
*/ | ||
window.addHook('search', (query) => { | ||
const added = new Set(); | ||
|
||
// Regular expression for decimal coordinates | ||
const decimalRegex = /[+-]?\d+\.\d+, ?[+-]?\d+\.\d+/g; | ||
// Regular expression for DMS coordinates | ||
const dmsRegex = /(\d{1,3})°(\d{1,2})'(\d{1,2}(?:\.\d+)?)?"\s*([NS]),?\s*(\d{1,3})°(\d{1,2})'(\d{1,2}(?:\.\d+)?)?"\s*([EW])/g; | ||
|
||
// Convert DMS to decimal format | ||
const parseDMS = (deg, min, sec, dir) => { | ||
const decimal = parseFloat(deg) + parseFloat(min) / 60 + parseFloat(sec) / 3600; | ||
return dir === 'S' || dir === 'W' ? -decimal : decimal; | ||
}; | ||
|
||
// Universal function for adding search result | ||
const addResult = (lat, lng) => { | ||
const latLngString = `${lat.toFixed(6)},${lng.toFixed(6)}`; | ||
if (added.has(latLngString)) return; | ||
added.add(latLngString); | ||
|
||
query.addResult({ | ||
title: latLngString, | ||
description: 'geo coordinates', | ||
position: L.latLng(lat, lng), | ||
onSelected: (result) => { | ||
for (const [guid, portal] of Object.entries(window.portals)) { | ||
const { lat: pLat, lng: pLng } = portal.getLatLng(); | ||
if (`${pLat.toFixed(6)},${pLng.toFixed(6)}` === latLngString) { | ||
window.renderPortalDetails(guid); | ||
return; | ||
} | ||
} | ||
window.urlPortalLL = [result.position.lat, result.position.lng]; | ||
}, | ||
}); | ||
}; | ||
|
||
// Search and process decimal coordinates | ||
const decimalMatches = query.term.replace(/%2C/gi, ',').match(decimalRegex); | ||
if (decimalMatches) { | ||
decimalMatches.forEach((location) => { | ||
const [lat, lng] = location.split(',').map(Number); | ||
addResult(lat, lng); | ||
}); | ||
} | ||
|
||
// Search and process DMS coordinates | ||
const dmsMatches = Array.from(query.term.matchAll(dmsRegex)); | ||
dmsMatches.forEach((match) => { | ||
const lat = parseDMS(match[1], match[2], match[3], match[4]); | ||
const lng = parseDMS(match[5], match[6], match[7], match[8]); | ||
addResult(lat, lng); | ||
}); | ||
}); | ||
|
||
/** | ||
* Searches for results on OpenStreetMap based on the query term, considering map view boundaries. | ||
* | ||
* @param {Object} query - The search query object. | ||
* @fires hook#search | ||
*/ | ||
window.addHook('search', async (query) => { | ||
if (!query.confirmed) return; | ||
|
||
const mapBounds = window.map.getBounds(); | ||
const viewbox = `&viewbox=${mapBounds.getSouthWest().lng},${mapBounds.getSouthWest().lat},${mapBounds.getNorthEast().lng},${mapBounds.getNorthEast().lat}`; | ||
// Bounded search allows amenity-only searches (e.g. "amenity=toilet") via special phrases | ||
// https://wiki.openstreetmap.org/wiki/Nominatim/Special_Phrases/EN | ||
const bounded = '&bounded=1'; | ||
|
||
const resultMap = new Set(); | ||
let resultCount = 0; | ||
|
||
async function fetchResults(isViewboxResult) { | ||
try { | ||
const response = await fetch(`${window.NOMINATIM}${encodeURIComponent(query.term)}${isViewboxResult ? viewbox + bounded : viewbox}`); | ||
const data = await response.json(); | ||
|
||
if (isViewboxResult && data.length === 0) { | ||
// If no results found within the viewbox, try a broader search | ||
await fetchResults(false); | ||
return; | ||
} else if (!isViewboxResult && resultCount === 0 && data.length === 0) { | ||
// If no results at all | ||
query.addResult({ | ||
title: 'No results on OpenStreetMap', | ||
icon: '//www.openstreetmap.org/favicon.ico', | ||
onSelected: () => true, | ||
}); | ||
return; | ||
} | ||
|
||
resultCount += data.length; | ||
|
||
data.forEach((item) => { | ||
if (resultMap.has(item.place_id)) return; // duplicate | ||
resultMap.add(item.place_id); | ||
|
||
const result = { | ||
title: item.display_name, | ||
description: `Type: ${item.type}`, | ||
position: L.latLng(parseFloat(item.lat), parseFloat(item.lon)), | ||
icon: item.icon, | ||
}; | ||
|
||
if (item.geojson) { | ||
result.layer = L.geoJson(item.geojson, { | ||
interactive: false, | ||
color: 'red', | ||
opacity: 0.7, | ||
weight: 2, | ||
fill: false, | ||
pointToLayer: (featureData, latLng) => | ||
L.marker(latLng, { | ||
icon: L.divIcon.coloredSvg('red'), | ||
title: item.display_name, | ||
}), | ||
}); | ||
} | ||
|
||
if (item.boundingbox) { | ||
const [south, north, west, east] = item.boundingbox; | ||
result.bounds = new L.LatLngBounds(L.latLng(parseFloat(south), parseFloat(west)), L.latLng(parseFloat(north), parseFloat(east))); | ||
} | ||
|
||
query.addResult(result); | ||
}); | ||
} catch (error) { | ||
console.error('Error fetching OSM data:', error); | ||
} | ||
} | ||
|
||
// Start with viewbox-bounded search | ||
await fetchResults(true); | ||
}); | ||
|
||
/** | ||
* Searches by GUID in the query term. | ||
* | ||
* @param {Object} query - The search query object. | ||
* @fires hook#search | ||
*/ | ||
window.addHook('search', async (query) => { | ||
const guidRegex = /[0-9a-f]{32}\.[0-9a-f]{2}/; | ||
const match = query.term.match(guidRegex); | ||
|
||
if (match) { | ||
const guid = match[0]; | ||
const data = window.portalDetail.get(guid); | ||
|
||
if (data) { | ||
window.search.addSearchResult(query, data, guid); | ||
} else { | ||
try { | ||
const fetchedData = await window.portalDetail.request(guid); | ||
window.search.addSearchResult(query, fetchedData, guid); | ||
} catch (error) { | ||
console.error('Error fetching portal details:', error); | ||
} | ||
} | ||
} | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,317 @@ | ||
/* global IITC, L -- eslint */ | ||
|
||
/** | ||
* @memberOf IITC.search.Query | ||
* @typedef {Object} SearchResult | ||
* @property {string} title - The label for this result (HTML-formatted). | ||
* @property {string} [description] - Secondary information for this result (HTML-formatted). | ||
* @property {L.LatLng} [position] - Position of this result. | ||
* @property {L.LatLngBounds} [bounds] - Bounds of this result. | ||
* @property {L.Layer|null} [layer] - Layer to be added to the map on result selection. | ||
* @property {string} [icon] - URL to a 12x12px icon for the result list. | ||
* @property {IITC.search.Query.onSelectedCallback} [onSelected] - Handler called when result is selected. | ||
* May return `true` to prevent the map from being repositioned. | ||
* You may reposition the map yourself or do other work. | ||
* @property {IITC.search.Query.onRemoveCallback} [onRemove] - Handler called when result is removed from map. | ||
* (because another result has been selected or the search was cancelled by the user). | ||
*/ | ||
|
||
/** | ||
* @memberOf IITC.search.Query | ||
* @callback onSelectedCallback | ||
* @param {IITC.search.Query.SearchResult} result - The selected search result. | ||
* @param {Event} event - The event that triggered the selection. | ||
* @returns {boolean} - Returns true to prevent map repositioning. | ||
*/ | ||
|
||
/** | ||
* @memberOf IITC.search.Query | ||
* @callback onRemoveCallback | ||
* @param {IITC.search.Query.SearchResult} result - The search result that is being removed. | ||
* @returns {void} - No return value. | ||
*/ | ||
|
||
/** | ||
* Represents a search query within the IITC search module, managing query state, results, and UI rendering. | ||
* | ||
* This class provides functionality to handle search operations such as displaying and interacting with results, | ||
* including selection, hover actions, and map adjustments. Hooks for custom search actions are triggered when | ||
* a new search query is initialized. | ||
* | ||
* @memberof IITC.search | ||
* @class | ||
*/ | ||
class Query { | ||
/** | ||
* Initializes the search query, setting up UI elements and triggering the 'search' hook. | ||
* | ||
* @constructor | ||
* @param {string} term - The search term. | ||
* @param {boolean} confirmed - Indicates if the search is confirmed (e.g., by pressing Enter). | ||
*/ | ||
constructor(term, confirmed) { | ||
this.term = term; | ||
this.confirmed = confirmed; | ||
this.results = []; | ||
this.resultsView = new IITC.search.QueryResultsView(term, confirmed); | ||
|
||
window.runHooks('search', this); | ||
} | ||
|
||
/** | ||
* Displays the search query results in the specified resultsView container. | ||
* | ||
* @memberof IITC.search.Query | ||
* @function show | ||
* @private | ||
*/ | ||
show() { | ||
this.resultsView.renderIn('#searchwrapper'); | ||
} | ||
|
||
/** | ||
* Hides and removes the current search results, clearing selection and hover states. | ||
* | ||
* @memberof IITC.search.Query | ||
* @function show | ||
* @private | ||
*/ | ||
hide() { | ||
this.resultsView.remove(); | ||
this.removeSelectedResult(); | ||
this.removeHoverResult(); | ||
} | ||
|
||
/** | ||
* Adds a search result to the query and triggers re-rendering of the results list. | ||
* | ||
* @memberof IITC.search.Query | ||
* @function addResult | ||
* @param {IITC.search.Query.SearchResult} result - The search result to add, including title, position, and interactions. | ||
*/ | ||
addResult(result) { | ||
this.results.push(result); | ||
this.renderResults(); | ||
} | ||
|
||
/** | ||
* Adds a search result for a portal to the search query results. | ||
* | ||
* @memberof IITC.search.Query | ||
* @function addPortalResult | ||
* @param {Object} data - The portal data for the search result. This includes information such as title, team, level, health, etc. | ||
* @param {string} guid - GUID if the portal. | ||
*/ | ||
addPortalResult(data, guid) { | ||
const team = window.teamStringToId(data.team); | ||
const color = team === window.TEAM_NONE ? '#CCC' : window.COLORS[team]; | ||
const latLng = L.latLng(data.latE6 / 1e6, data.lngE6 / 1e6); | ||
|
||
this.addResult({ | ||
title: data.title, | ||
description: `${window.TEAM_SHORTNAMES[team]}, L${data.level}, ${data.health}%, ${data.resCount} Resonators`, | ||
position: latLng, | ||
icon: `data:image/svg+xml;base64,${btoa('@include_string:images/icon-portal.svg@'.replace(/%COLOR%/g, color))}`, | ||
|
||
onSelected(result, event) { | ||
const { position } = result; | ||
|
||
if (event.type === 'dblclick') { | ||
window.zoomToAndShowPortal(guid, latLng); | ||
} else if (window.portals[guid]) { | ||
if (!window.map.getBounds().contains(position)) { | ||
window.map.setView(position); | ||
} | ||
window.renderPortalDetails(guid); | ||
} else { | ||
window.selectPortalByLatLng(latLng); | ||
} | ||
return true; | ||
}, | ||
}); | ||
} | ||
|
||
/** | ||
* Handles keyboard interactions for selecting a result with Enter or Space keys. | ||
* | ||
* @memberof IITC.search.Query | ||
* @function handleKeyPress | ||
* @param {Event} ev - The keyboard event. | ||
* @param {Object} result - The result being interacted with. | ||
* @private | ||
*/ | ||
handleKeyPress(ev, result) { | ||
if (ev.key === ' ' || ev.key === 'Enter') { | ||
ev.preventDefault(); | ||
const type = ev.key === ' ' ? 'click' : 'dblclick'; | ||
this.onResultSelected(result, { ...ev, type }); | ||
} | ||
} | ||
|
||
/** | ||
* Renders all search results through the resultsView class and sets up event handling for each result. | ||
* | ||
* @memberof IITC.search.Query | ||
* @function renderResults | ||
* @private | ||
*/ | ||
renderResults() { | ||
this.resultsView.renderResults(this.results, (result, event) => this.handleResultInteraction(result, event)); | ||
} | ||
|
||
/** | ||
* Manages interactions with search results, such as clicks, hovers, and keyboard events. | ||
* | ||
* @memberof IITC.search.Query | ||
* @function handleResultInteraction | ||
* @param {Object} result - The result being interacted with. | ||
* @param {Event} event - The event associated with the interaction. | ||
* @private | ||
*/ | ||
handleResultInteraction(result, event) { | ||
switch (event.type) { | ||
case 'click': | ||
case 'dblclick': | ||
this.onResultSelected(result, event); | ||
break; | ||
case 'mouseover': | ||
this.onResultHoverStart(result); | ||
break; | ||
case 'mouseout': | ||
this.onResultHoverEnd(); | ||
break; | ||
case 'keydown': | ||
this.handleKeyPress(event, result); | ||
break; | ||
} | ||
} | ||
|
||
/** | ||
* Creates and returns a map layer for the given search result, which could include markers or shapes. | ||
* | ||
* @memberof IITC.search.Query | ||
* @function resultLayer | ||
* @param {Object} result - The search result object. | ||
* @returns {L.Layer} - The generated layer for the result. | ||
* @private | ||
*/ | ||
resultLayer(result) { | ||
if (!result.layer) { | ||
result.layer = L.layerGroup(); | ||
|
||
if (result.position) { | ||
L.marker(result.position, { | ||
icon: L.divIcon.coloredSvg('red'), | ||
title: result.title, | ||
}).addTo(result.layer); | ||
} | ||
|
||
if (result.bounds) { | ||
L.rectangle(result.bounds, { | ||
title: result.title, | ||
interactive: false, | ||
color: 'red', | ||
fill: false, | ||
}).addTo(result.layer); | ||
} | ||
} | ||
return result.layer; | ||
} | ||
|
||
/** | ||
* Handles the selection of a search result, adjusting the map view and adding its layer to the map. | ||
* | ||
* @memberof IITC.search.Query | ||
* @function onResultSelected | ||
* @param {Object} result - The selected search result object. | ||
* @param {Event} event - The event associated with the selection. | ||
* @private | ||
*/ | ||
onResultSelected(result, event) { | ||
this.removeHoverResult(); | ||
this.removeSelectedResult(); | ||
this.selectedResult = result; | ||
|
||
if (result.onSelected && result.onSelected(result, event)) return; | ||
|
||
const { position, bounds } = result; | ||
if (event.type === 'dblclick') { | ||
if (position) { | ||
window.map.setView(position, window.DEFAULT_ZOOM); | ||
} else if (bounds) { | ||
window.map.fitBounds(bounds, { maxZoom: window.DEFAULT_ZOOM }); | ||
} | ||
} else { | ||
if (bounds) { | ||
window.map.fitBounds(bounds, { maxZoom: window.DEFAULT_ZOOM }); | ||
} else if (position) { | ||
window.map.setView(position); | ||
} | ||
} | ||
|
||
result.layer = this.resultLayer(result); | ||
|
||
if (result.layer) window.map.addLayer(result.layer); | ||
if (window.isSmartphone()) window.show('map'); | ||
} | ||
|
||
/** | ||
* Removes the currently selected search result from the map and performs necessary cleanup. | ||
* | ||
* @memberof IITC.search.Query | ||
* @function removeSelectedResult | ||
* @private | ||
*/ | ||
removeSelectedResult() { | ||
if (this.selectedResult) { | ||
if (this.selectedResult.layer) window.map.removeLayer(this.selectedResult.layer); | ||
if (this.selectedResult.onRemove) this.selectedResult.onRemove(this.selectedResult); | ||
} | ||
} | ||
|
||
/** | ||
* Starts a hover interaction on a search result, displaying its layer on the map. | ||
* | ||
* @memberof IITC.search.Query | ||
* @function onResultHoverStart | ||
* @param {Object} result - The result being hovered over. | ||
* @private | ||
*/ | ||
onResultHoverStart(result) { | ||
this.removeHoverResult(); | ||
this.hoverResult = result; | ||
|
||
if (result === this.selectedResult) return; | ||
|
||
result.layer = this.resultLayer(result); | ||
|
||
if (result.layer) window.map.addLayer(result.layer); | ||
} | ||
|
||
/** | ||
* Ends a hover interaction by removing the hover layer from the map if it is not selected. | ||
* | ||
* @memberof IITC.search.Query | ||
* @function removeHoverResult | ||
* @private | ||
*/ | ||
removeHoverResult() { | ||
if (this.hoverResult && this.hoverResult.layer && this.hoverResult !== this.selectedResult) { | ||
window.map.removeLayer(this.hoverResult.layer); | ||
} | ||
this.hoverResult = null; | ||
} | ||
|
||
/** | ||
* Handles the end of a hover event, removing the hover layer from the map. | ||
* | ||
* @memberof IITC.search.Query | ||
* @function onResultHoverEnd | ||
* @private | ||
*/ | ||
onResultHoverEnd() { | ||
this.removeHoverResult(); | ||
} | ||
} | ||
|
||
IITC.search.Query = Query; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,197 @@ | ||
/* global IITC -- eslint */ | ||
|
||
/** | ||
* Represents the view for displaying search query results in the IITC search module. | ||
* | ||
* @memberof IITC.search | ||
* @class | ||
*/ | ||
class QueryResultsView { | ||
/** | ||
* Initializes the query results view, setting up the display elements for the search term. | ||
* | ||
* @constructor | ||
* @param {string} term - The search term. | ||
* @param {boolean} confirmed - Indicates if the search is confirmed (e.g., by pressing Enter). | ||
*/ | ||
constructor(term, confirmed) { | ||
this.term = term; | ||
this.confirmed = confirmed; | ||
this.container = this.createContainer(); | ||
this.header = this.createHeader(); | ||
this.list = this.createList(); | ||
this.setupAccordion(); | ||
} | ||
|
||
/** | ||
* Creates and returns the main container element for the query results. | ||
* | ||
* @memberof IITC.search.QueryResultsView | ||
* @function createContainer | ||
* @returns {HTMLElement} - The container element for the results. | ||
* @private | ||
*/ | ||
createContainer() { | ||
const container = document.createElement('div'); | ||
container.classList.add('searchquery'); | ||
return container; | ||
} | ||
|
||
/** | ||
* Creates and appends a header to the container based on the search term. | ||
* | ||
* @memberof IITC.search.QueryResultsView | ||
* @function createHeader | ||
* @returns {HTMLElement} - The header element displaying the search term or a loading message. | ||
* @private | ||
*/ | ||
createHeader() { | ||
const header = document.createElement('h3'); | ||
let headerText; | ||
|
||
if (this.confirmed) { | ||
headerText = this.term; | ||
} else { | ||
if (this.term.length > 16) { | ||
const start = this.term.slice(0, 8); | ||
const end = this.term.slice(-8); | ||
headerText = `${start}…${end} (Return to load more)`; | ||
} else { | ||
headerText = `${this.term} (Return to load more)`; | ||
} | ||
} | ||
|
||
header.textContent = headerText; | ||
this.container.appendChild(header); | ||
return header; | ||
} | ||
|
||
/** | ||
* Creates and appends an initial list element to display the search results. | ||
* | ||
* @memberof IITC.search.QueryResultsView | ||
* @function createList | ||
* @returns {HTMLElement} - The list element for displaying the results. | ||
* @private | ||
*/ | ||
createList() { | ||
const list = document.createElement('ul'); | ||
const initialItem = document.createElement('li'); | ||
initialItem.textContent = this.confirmed ? 'No local results, searching online...' : 'No local results.'; | ||
list.appendChild(initialItem); | ||
this.container.appendChild(list); | ||
return list; | ||
} | ||
|
||
/** | ||
* Sets up the accordion functionality for expanding and collapsing results. | ||
* | ||
* @memberof IITC.search.QueryResultsView | ||
* @function setupAccordion | ||
* @private | ||
*/ | ||
setupAccordion() { | ||
this.header.addEventListener('click', () => { | ||
this.container.classList.toggle('collapsed'); | ||
}); | ||
} | ||
|
||
/** | ||
* Renders the search results within the list container and sets up event interactions. | ||
* | ||
* @memberof IITC.search.QueryResultsView | ||
* @function renderResults | ||
* @param {Array<Object>} results - An array of search result objects to display. | ||
* @param {Function} onResultInteraction - A callback function for handling interaction events on results. | ||
*/ | ||
renderResults(results, onResultInteraction) { | ||
this.clearList(); | ||
|
||
if (results.length === 0) { | ||
const noResultsItem = document.createElement('li'); | ||
noResultsItem.textContent = 'No results found.'; | ||
this.list.appendChild(noResultsItem); | ||
} else { | ||
results.forEach((result) => { | ||
const item = this.createListItem(result); | ||
item.addEventListener('click', (ev) => onResultInteraction(result, ev)); | ||
item.addEventListener('dblclick', (ev) => onResultInteraction(result, ev)); | ||
item.addEventListener('mouseover', (ev) => onResultInteraction(result, ev)); | ||
item.addEventListener('mouseout', (ev) => onResultInteraction(result, ev)); | ||
item.addEventListener('keydown', (ev) => onResultInteraction(result, ev)); | ||
this.list.appendChild(item); | ||
}); | ||
} | ||
} | ||
|
||
/** | ||
* Creates and returns a list item for an individual search result. | ||
* | ||
* @memberof IITC.search.QueryResultsView | ||
* @function createListItem | ||
* @param {Object} result - The search result object with properties such as title, description, and icon. | ||
* @returns {HTMLElement} - The list item element representing the search result. | ||
* @private | ||
*/ | ||
createListItem(result) { | ||
const item = document.createElement('li'); | ||
item.tabIndex = 0; | ||
|
||
const link = document.createElement('a'); | ||
link.innerHTML = result.title; | ||
|
||
if (result.icon) { | ||
link.style.backgroundImage = `url("${result.icon}")`; | ||
item.style.listStyle = 'none'; | ||
} | ||
|
||
item.appendChild(link); | ||
|
||
if (result.description) { | ||
const description = document.createElement('em'); | ||
description.innerHTML = result.description; | ||
item.appendChild(document.createElement('br')); | ||
item.appendChild(description); | ||
} | ||
|
||
return item; | ||
} | ||
|
||
/** | ||
* Appends the results container to a specified selector on the page. | ||
* | ||
* @memberof IITC.search.QueryResultsView | ||
* @function renderIn | ||
* @param {string} selector - The selector string for the target container. | ||
*/ | ||
renderIn(selector) { | ||
const target = document.querySelector(selector); | ||
if (target) target.appendChild(this.container); | ||
} | ||
|
||
/** | ||
* Removes the results container from the page. | ||
* | ||
* @memberof IITC.search.QueryResultsView | ||
* @function remove | ||
* @private | ||
*/ | ||
remove() { | ||
if (this.container.parentNode) { | ||
this.container.parentNode.removeChild(this.container); | ||
} | ||
} | ||
|
||
/** | ||
* Clears all items from the results list. | ||
* | ||
* @memberof IITC.search.QueryResultsView | ||
* @function clearList | ||
* @private | ||
*/ | ||
clearList() { | ||
this.list.innerHTML = ''; | ||
} | ||
} | ||
|
||
IITC.search.QueryResultsView = QueryResultsView; |
Binary file not shown.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,176 @@ | ||
import { describe, it, beforeEach } from 'mocha'; | ||
import { expect } from 'chai'; | ||
|
||
/* global IITC, L */ | ||
/* eslint-disable no-unused-expressions */ | ||
|
||
if (!globalThis.window) globalThis.window = {}; | ||
if (!globalThis.L) globalThis.L = {}; | ||
if (!globalThis.IITC) globalThis.IITC = {}; | ||
if (!globalThis.IITC.search) globalThis.IITC.search = {}; | ||
globalThis.IITC.search.Query = {}; | ||
import('../core/code/search_query.js'); | ||
|
||
describe('IITC.search.Query', () => { | ||
let query; | ||
let fakeMap; | ||
|
||
beforeEach(() => { | ||
// Mock objects and methods | ||
fakeMap = { | ||
addTo: () => {}, | ||
addLayer: () => {}, | ||
}; | ||
|
||
globalThis.window = { | ||
...globalThis.window, | ||
...{ | ||
map: fakeMap, | ||
runHooks: () => {}, | ||
isSmartphone: () => false, | ||
TEAM_SHORTNAMES: { NEUTRAL: 'NEU', ENLIGHTENED: 'ENL' }, | ||
COLORS: { NEUTRAL: '#CCC', ENLIGHTENED: '#008000' }, | ||
teamStringToId: (team) => (team === 'ENLIGHTENED' ? 'ENLIGHTENED' : 'NEUTRAL'), | ||
}, | ||
}; | ||
|
||
globalThis.L = { | ||
...globalThis.L, | ||
...{ | ||
LatLng: class {}, | ||
latLng: (lat, lng) => new L.LatLng(lat, lng), | ||
layerGroup: () => fakeMap, | ||
marker: () => fakeMap, | ||
divIcon: { | ||
coloredSvg: () => {}, | ||
}, | ||
}, | ||
}; | ||
|
||
globalThis.IITC.search.QueryResultsView = class { | ||
constructor(term, confirmed) { | ||
this.term = term; | ||
this.confirmed = confirmed; | ||
} | ||
renderResults() {} | ||
}; | ||
|
||
query = new IITC.search.Query('test', true); | ||
}); | ||
|
||
// Test for initialization | ||
it('should initialize with an empty results array', () => { | ||
expect(query.results).to.be.an('array').that.is.empty; | ||
}); | ||
|
||
// Test for the addResult method | ||
it('should add a result to the results array with addResult', () => { | ||
const mockResult = { title: 'Test Result', position: new L.LatLng(0, 0) }; | ||
|
||
query.addResult(mockResult); | ||
|
||
expect(query.results).to.have.lengthOf(1); | ||
expect(query.results[0]).to.deep.equal(mockResult); | ||
|
||
let renderCalled = false; | ||
query.renderResults = () => { | ||
renderCalled = true; | ||
}; | ||
query.addResult(mockResult); | ||
expect(renderCalled).to.be.true; | ||
}); | ||
|
||
// Test for the addPortalResult method | ||
it('should add a portal result to the results array with addPortalResult', () => { | ||
const portalData = { | ||
title: 'Test Portal', | ||
team: 'ENLIGHTENED', | ||
level: 8, | ||
health: 100, | ||
resCount: 8, | ||
latE6: 50000000, | ||
lngE6: 100000000, | ||
}; | ||
|
||
query.addPortalResult(portalData, 'abc123'); | ||
|
||
expect(query.results).to.have.lengthOf(1); | ||
const addedResult = query.results[0]; | ||
|
||
expect(addedResult.title).to.equal('Test Portal'); | ||
expect(addedResult.description).to.contain('ENL'); | ||
expect(addedResult.description).to.contain('L8'); | ||
expect(addedResult.description).to.contain('100%'); | ||
expect(addedResult.description).to.contain('8 Resonators'); | ||
expect(addedResult.icon).to.be.a('string').that.contains('data:image/svg+xml;base64'); | ||
}); | ||
|
||
// Test for hover interaction handling | ||
it('should start hover interaction and add layer to map', () => { | ||
const mockResult = { title: 'Hover Result', layer: null, position: new L.LatLng(0, 0) }; | ||
let layerAdded = false; | ||
|
||
fakeMap.addLayer = () => { | ||
layerAdded = true; | ||
}; | ||
query.onResultHoverStart(mockResult); | ||
|
||
expect(layerAdded).to.be.true; | ||
}); | ||
|
||
it('should handle Space key press for selecting a result', () => { | ||
let eventHandled = false; | ||
const mockEvent = { key: ' ', preventDefault: () => {} }; | ||
const result = { title: 'Test Result' }; | ||
|
||
query.onResultSelected = () => { | ||
eventHandled = true; | ||
}; | ||
|
||
query.handleKeyPress(mockEvent, result); | ||
expect(eventHandled).to.be.true; | ||
}); | ||
|
||
it('should remove hover interaction layer from map', () => { | ||
const mockResult = { layer: fakeMap }; | ||
let layerRemoved = false; | ||
|
||
query.hoverResult = mockResult; | ||
fakeMap.removeLayer = () => { | ||
layerRemoved = true; | ||
}; | ||
|
||
query.removeHoverResult(); | ||
expect(layerRemoved).to.be.true; | ||
}); | ||
|
||
// Test for selecting a result | ||
it('should select a result and adjust the map view', () => { | ||
const mockResult = { | ||
title: 'Selected Result', | ||
position: new L.LatLng(0, 0), | ||
onSelected: () => false, | ||
}; | ||
let viewSet = false; | ||
|
||
fakeMap.setView = () => { | ||
viewSet = true; | ||
}; | ||
query.onResultSelected(mockResult, { type: 'click' }); | ||
|
||
expect(viewSet).to.be.true; | ||
expect(query.selectedResult).to.equal(mockResult); | ||
}); | ||
|
||
it('should prevent map repositioning if onSelected returns true', () => { | ||
const mockResult = { title: 'Selected Result', onSelected: () => true }; | ||
let viewSet = false; | ||
|
||
fakeMap.setView = () => { | ||
viewSet = true; | ||
}; | ||
query.onResultSelected(mockResult, { type: 'click' }); | ||
|
||
expect(viewSet).to.be.false; | ||
}); | ||
}); |