Add category search, and search result markers with icons #35
@@ -6,10 +6,16 @@ import { on } from '@ember/modifier';
|
|||||||
import Icon from '#components/icon';
|
import Icon from '#components/icon';
|
||||||
import UserMenu from '#components/user-menu';
|
import UserMenu from '#components/user-menu';
|
||||||
import SearchBox from '#components/search-box';
|
import SearchBox from '#components/search-box';
|
||||||
|
import CategoryChips from '#components/category-chips';
|
||||||
|
|
||||||
export default class AppHeaderComponent extends Component {
|
export default class AppHeaderComponent extends Component {
|
||||||
@service storage;
|
@service storage;
|
||||||
@tracked isUserMenuOpen = false;
|
@tracked isUserMenuOpen = false;
|
||||||
|
@tracked searchQuery = '';
|
||||||
|
|
||||||
|
get hasQuery() {
|
||||||
|
return !!this.searchQuery;
|
||||||
|
}
|
||||||
|
|
||||||
@action
|
@action
|
||||||
toggleUserMenu() {
|
toggleUserMenu() {
|
||||||
@@ -21,10 +27,30 @@ export default class AppHeaderComponent extends Component {
|
|||||||
this.isUserMenuOpen = false;
|
this.isUserMenuOpen = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
handleQueryChange(query) {
|
||||||
|
this.searchQuery = query;
|
||||||
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
handleChipSelect(category) {
|
||||||
|
this.searchQuery = category.label;
|
||||||
|
// The existing logic in CategoryChips triggers the route transition.
|
||||||
|
// This update simply fills the search box.
|
||||||
|
}
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<header class="app-header">
|
<header class="app-header">
|
||||||
<div class="header-left">
|
<div class="header-left">
|
||||||
<SearchBox @onToggleMenu={{@onToggleMenu}} />
|
<SearchBox
|
||||||
|
@query={{this.searchQuery}}
|
||||||
|
@onToggleMenu={{@onToggleMenu}}
|
||||||
|
@onQueryChange={{this.handleQueryChange}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="header-center {{if this.hasQuery 'searching'}}">
|
||||||
|
<CategoryChips @onSelect={{this.handleChipSelect}} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="header-right">
|
<div class="header-right">
|
||||||
|
|||||||
57
app/components/category-chips.gjs
Normal file
57
app/components/category-chips.gjs
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import Component from '@glimmer/component';
|
||||||
|
import { service } from '@ember/service';
|
||||||
|
import { action } from '@ember/object';
|
||||||
|
import { on } from '@ember/modifier';
|
||||||
|
import { fn } from '@ember/helper';
|
||||||
|
import Icon from '#components/icon';
|
||||||
|
import { POI_CATEGORIES } from '../utils/poi-categories';
|
||||||
|
import { eq, and } from 'ember-truth-helpers';
|
||||||
|
|
||||||
|
export default class CategoryChipsComponent extends Component {
|
||||||
|
@service router;
|
||||||
|
@service mapUi;
|
||||||
|
|
||||||
|
get categories() {
|
||||||
|
return POI_CATEGORIES;
|
||||||
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
searchCategory(category) {
|
||||||
|
// If passed an onSelect action, call it (e.g. to clear search box)
|
||||||
|
if (this.args.onSelect) {
|
||||||
|
this.args.onSelect(category);
|
||||||
|
}
|
||||||
|
|
||||||
|
let queryParams = { category: category.id, q: null };
|
||||||
|
|
||||||
|
if (this.mapUi.currentCenter) {
|
||||||
|
const { lat, lon } = this.mapUi.currentCenter;
|
||||||
|
queryParams.lat = parseFloat(lat).toFixed(4);
|
||||||
|
queryParams.lon = parseFloat(lon).toFixed(4);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.router.transitionTo('search', { queryParams });
|
||||||
|
}
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="category-chips-scroll">
|
||||||
|
<div class="category-chips-container">
|
||||||
|
{{#each this.categories as |category|}}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="category-chip"
|
||||||
|
{{on "click" (fn this.searchCategory category)}}
|
||||||
|
aria-label={{category.label}}
|
||||||
|
disabled={{and
|
||||||
|
(eq this.mapUi.loadingState.type "category")
|
||||||
|
(eq this.mapUi.loadingState.value category.id)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon @name={{category.icon}} @size={{16}} />
|
||||||
|
<span>{{category.label}}</span>
|
||||||
|
</button>
|
||||||
|
{{/each}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import Component from '@glimmer/component';
|
import Component from '@glimmer/component';
|
||||||
import { htmlSafe } from '@ember/template';
|
import { htmlSafe } from '@ember/template';
|
||||||
import { getIcon } from '../utils/icons';
|
import { getIcon, isIconFilled } from '../utils/icons';
|
||||||
|
|
||||||
export default class IconComponent extends Component {
|
export default class IconComponent extends Component {
|
||||||
get svg() {
|
get svg() {
|
||||||
@@ -25,10 +25,14 @@ export default class IconComponent extends Component {
|
|||||||
return this.args.title || '';
|
return this.args.title || '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get isFilled() {
|
||||||
|
return this.args.filled || isIconFilled(this.args.name);
|
||||||
|
}
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
{{#if this.svg}}
|
{{#if this.svg}}
|
||||||
<span
|
<span
|
||||||
class="icon {{if @filled 'icon-filled'}}"
|
class="icon {{if this.isFilled 'icon-filled'}}"
|
||||||
style={{this.style}}
|
style={{this.style}}
|
||||||
title={{this.title}}
|
title={{this.title}}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import Component from '@glimmer/component';
|
|||||||
import { service } from '@ember/service';
|
import { service } from '@ember/service';
|
||||||
import { modifier } from 'ember-modifier';
|
import { modifier } from 'ember-modifier';
|
||||||
import 'ol/ol.css';
|
import 'ol/ol.css';
|
||||||
import Map from 'ol/Map.js';
|
import OlMap from 'ol/Map.js';
|
||||||
import { defaults as defaultControls, Control } from 'ol/control.js';
|
import { defaults as defaultControls, Control } from 'ol/control.js';
|
||||||
import { defaults as defaultInteractions, DragPan } from 'ol/interaction.js';
|
import { defaults as defaultInteractions, DragPan } from 'ol/interaction.js';
|
||||||
import Kinetic from 'ol/Kinetic.js';
|
import Kinetic from 'ol/Kinetic.js';
|
||||||
@@ -16,8 +16,10 @@ 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';
|
||||||
|
import { getIcon } from '../utils/icons';
|
||||||
|
import { getIconNameForTags } from '../utils/osm-icons';
|
||||||
|
|
||||||
export default class MapComponent extends Component {
|
export default class MapComponent extends Component {
|
||||||
@service osm;
|
@service osm;
|
||||||
@@ -28,6 +30,7 @@ export default class MapComponent extends Component {
|
|||||||
|
|
||||||
mapInstance;
|
mapInstance;
|
||||||
bookmarkSource;
|
bookmarkSource;
|
||||||
|
searchResultsSource;
|
||||||
selectedShapeSource;
|
selectedShapeSource;
|
||||||
searchOverlay;
|
searchOverlay;
|
||||||
searchOverlayElement;
|
searchOverlayElement;
|
||||||
@@ -110,6 +113,101 @@ 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();
|
||||||
|
const cachedIconUrls = new Map();
|
||||||
|
|
||||||
|
const searchResultStyle = (feature) => {
|
||||||
|
const originalPlace = feature.get('originalPlace');
|
||||||
|
// Some search results might be just the place object without separate tags
|
||||||
|
// If it's a raw place object, it might have osmTags property.
|
||||||
|
// Or it might be the tags object itself.
|
||||||
|
const tags = originalPlace.osmTags || originalPlace;
|
||||||
|
const iconName = getIconNameForTags(tags);
|
||||||
|
|
||||||
|
// Use 'default' key for the standard red dot marker. Use iconName as key if present.
|
||||||
|
const cacheKey = iconName || 'default';
|
||||||
|
|
||||||
|
if (!cachedIconUrls.has(cacheKey)) {
|
||||||
|
const markerColor =
|
||||||
|
getComputedStyle(document.documentElement)
|
||||||
|
.getPropertyValue('--marker-color-primary')
|
||||||
|
.trim() || '#ea4335';
|
||||||
|
|
||||||
|
// Default content: Red circle
|
||||||
|
let innerContent = `<circle cx="12" cy="12" r="8" fill="${markerColor}"/>`;
|
||||||
|
|
||||||
|
if (iconName) {
|
||||||
|
const rawSvg = getIcon(iconName);
|
||||||
|
if (rawSvg) {
|
||||||
|
// Pinhead icons are usually 15x15 viewBox="0 0 15 15".
|
||||||
|
// We want to center it on 12,12.
|
||||||
|
// A 12x12 icon centered at 12,12 means top-left at 6,6.
|
||||||
|
// However, since we are embedding a new SVG, we can just use x/y/width/height.
|
||||||
|
// But we need to strip the outer <svg> tag to embed the paths cleanly if we want full control,
|
||||||
|
// or we can nest the SVG. Nesting is safer.
|
||||||
|
|
||||||
|
// The rawSvg string contains <svg ...>...</svg>.
|
||||||
|
// We want to make it white. We can add a group with fill="white".
|
||||||
|
// But if the SVG has fill attributes, they override. Pinhead icons usually don't have fills.
|
||||||
|
|
||||||
|
// Let's strip the outer SVG tag to get the path content.
|
||||||
|
let content = rawSvg.trim();
|
||||||
|
const svgStart = content.indexOf('<svg');
|
||||||
|
const svgEnd = content.indexOf('>', svgStart);
|
||||||
|
const contentStart = svgEnd + 1;
|
||||||
|
const contentEnd = content.lastIndexOf('</svg>');
|
||||||
|
|
||||||
|
if (svgStart !== -1 && contentEnd !== -1) {
|
||||||
|
content = content.substring(contentStart, contentEnd);
|
||||||
|
}
|
||||||
|
|
||||||
|
// We render the red circle background, then the icon on top.
|
||||||
|
// Icon is scaled down slightly to fit nicely inside the circle.
|
||||||
|
// 15x15 scaled by 0.8 is 12x12.
|
||||||
|
// Translate to 6,6 to center.
|
||||||
|
innerContent = `
|
||||||
|
<circle cx="12" cy="12" r="8" fill="${markerColor}"/>
|
||||||
|
<g transform="translate(6, 6) scale(0.8)" fill="white">
|
||||||
|
${content}
|
||||||
|
</g>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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)"/>
|
||||||
|
${innerContent}
|
||||||
|
</svg>
|
||||||
|
`;
|
||||||
|
|
||||||
|
cachedIconUrls.set(
|
||||||
|
cacheKey,
|
||||||
|
'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(svg.trim())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Style({
|
||||||
|
image: new Icon({
|
||||||
|
src: cachedIconUrls.get(cacheKey),
|
||||||
|
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;
|
||||||
@@ -141,9 +239,14 @@ export default class MapComponent extends Component {
|
|||||||
projection: 'EPSG:3857',
|
projection: 'EPSG:3857',
|
||||||
});
|
});
|
||||||
|
|
||||||
this.mapInstance = new Map({
|
this.mapInstance = new OlMap({
|
||||||
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 +281,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 +567,33 @@ 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) => {
|
||||||
|
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;
|
||||||
@@ -914,6 +1044,7 @@ export default class MapComponent extends Component {
|
|||||||
const [maxLon, maxLat] = toLonLat([extent[2], extent[3]]);
|
const [maxLon, maxLat] = toLonLat([extent[2], extent[3]]);
|
||||||
|
|
||||||
const bbox = { minLat, minLon, maxLat, maxLon };
|
const bbox = { minLat, minLon, maxLat, maxLon };
|
||||||
|
this.mapUi.updateBounds(bbox);
|
||||||
await this.storage.loadPlacesInBounds(bbox);
|
await this.storage.loadPlacesInBounds(bbox);
|
||||||
this.loadBookmarks(this.storage.placesInView);
|
this.loadBookmarks(this.storage.placesInView);
|
||||||
|
|
||||||
@@ -945,6 +1076,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) {
|
||||||
@@ -953,8 +1085,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();
|
||||||
@@ -963,16 +1099,30 @@ export default class MapComponent extends Component {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Helper to transition with proper state
|
||||||
|
const transitionToPlace = (place) => {
|
||||||
|
// If we are currently in search mode OR have active search results,
|
||||||
|
// we want the "Back" button on the place details to return to the search results.
|
||||||
|
if (
|
||||||
|
this.router.currentRouteName === 'search' ||
|
||||||
|
(this.mapUi.currentSearch && this.mapUi.searchResults.length > 0)
|
||||||
|
) {
|
||||||
|
this.mapUi.returnToSearch = true;
|
||||||
|
}
|
||||||
|
this.mapUi.preventNextZoom = true;
|
||||||
|
this.router.transitionTo('place', place);
|
||||||
|
};
|
||||||
|
|
||||||
// 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;
|
transitionToPlace(targetPlace);
|
||||||
this.router.transitionTo('place', clickedBookmark);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -986,8 +1136,13 @@ export default class MapComponent extends Component {
|
|||||||
// Normal behavior (sidebar is closed)
|
// Normal behavior (sidebar is closed)
|
||||||
if (clickedBookmark) {
|
if (clickedBookmark) {
|
||||||
console.debug('Clicked bookmark:', clickedBookmark);
|
console.debug('Clicked bookmark:', clickedBookmark);
|
||||||
this.mapUi.preventNextZoom = true;
|
transitionToPlace(clickedBookmark);
|
||||||
this.router.transitionTo('place', clickedBookmark);
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (clickedSearchResult) {
|
||||||
|
console.debug('Clicked search result:', clickedSearchResult);
|
||||||
|
transitionToPlace(clickedSearchResult);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1028,6 +1183,7 @@ export default class MapComponent extends Component {
|
|||||||
lat: lat.toFixed(6),
|
lat: lat.toFixed(6),
|
||||||
lon: lon.toFixed(6),
|
lon: lon.toFixed(6),
|
||||||
q: null, // Clear q to force spatial search
|
q: null, // Clear q to force spatial search
|
||||||
|
category: null, // Clear category to force spatial search
|
||||||
selected: selectedFeatureName || null,
|
selected: selectedFeatureName || null,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1040,6 +1196,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}}
|
||||||
|
|||||||
@@ -318,7 +318,7 @@ export default class PlaceDetails extends Component {
|
|||||||
|
|
||||||
{{#if this.cuisine}}
|
{{#if this.cuisine}}
|
||||||
<p class="content-with-icon">
|
<p class="content-with-icon">
|
||||||
<Icon @name="fork-and-knife" @title="Cuisine" @filled={{true}} />
|
<Icon @name="fork-and-knife" @title="Cuisine" />
|
||||||
<span>
|
<span>
|
||||||
{{this.cuisine}}
|
{{this.cuisine}}
|
||||||
</span>
|
</span>
|
||||||
@@ -393,7 +393,7 @@ export default class PlaceDetails extends Component {
|
|||||||
|
|
||||||
{{#if this.wikipedia}}
|
{{#if this.wikipedia}}
|
||||||
<p class="content-with-icon">
|
<p class="content-with-icon">
|
||||||
<Icon @name="wikipedia" @title="Wikipedia" @filled={{true}} />
|
<Icon @name="wikipedia" @title="Wikipedia" />
|
||||||
<span>
|
<span>
|
||||||
<a
|
<a
|
||||||
href="https://wikipedia.org/wiki/{{this.wikipedia}}"
|
href="https://wikipedia.org/wiki/{{this.wikipedia}}"
|
||||||
|
|||||||
@@ -146,7 +146,7 @@ export default class PlacesSidebar extends Component {
|
|||||||
|
|
||||||
get isNearbySearch() {
|
get isNearbySearch() {
|
||||||
const qp = this.router.currentRoute.queryParams;
|
const qp = this.router.currentRoute.queryParams;
|
||||||
return !qp.q && qp.lat && qp.lon;
|
return !qp.q && !qp.category && qp.lat && qp.lon;
|
||||||
}
|
}
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|||||||
@@ -7,38 +7,55 @@ import { fn } from '@ember/helper';
|
|||||||
import { task, timeout } from 'ember-concurrency';
|
import { task, timeout } from 'ember-concurrency';
|
||||||
import Icon from '#components/icon';
|
import Icon from '#components/icon';
|
||||||
import humanizeOsmTag from '../helpers/humanize-osm-tag';
|
import humanizeOsmTag from '../helpers/humanize-osm-tag';
|
||||||
import eq from 'ember-truth-helpers/helpers/eq';
|
import { POI_CATEGORIES } from '../utils/poi-categories';
|
||||||
|
import { eq, or } from 'ember-truth-helpers';
|
||||||
|
|
||||||
export default class SearchBoxComponent extends Component {
|
export default class SearchBoxComponent extends Component {
|
||||||
@service photon;
|
@service photon;
|
||||||
|
@service osm;
|
||||||
@service router;
|
@service router;
|
||||||
@service mapUi;
|
@service mapUi;
|
||||||
@service map; // Assuming we might need map context, but mostly we use router
|
@service map; // Assuming we might need map context, but mostly we use router
|
||||||
|
|
||||||
@tracked query = '';
|
@tracked _internalQuery = '';
|
||||||
@tracked results = [];
|
@tracked results = [];
|
||||||
@tracked isFocused = false;
|
@tracked isFocused = false;
|
||||||
@tracked isLoading = false;
|
@tracked isLoading = false;
|
||||||
|
|
||||||
|
get query() {
|
||||||
|
return this.args.query ?? this._internalQuery;
|
||||||
|
}
|
||||||
|
|
||||||
|
set query(value) {
|
||||||
|
this._internalQuery = value;
|
||||||
|
}
|
||||||
|
|
||||||
get showPopover() {
|
get showPopover() {
|
||||||
return this.isFocused && this.results.length > 0;
|
return this.isFocused && this.results.length > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@action
|
@action
|
||||||
handleInput(event) {
|
handleInput(event) {
|
||||||
this.query = event.target.value;
|
const value = event.target.value;
|
||||||
if (this.query.length < 2) {
|
this.query = value;
|
||||||
|
if (this.args.onQueryChange) {
|
||||||
|
this.args.onQueryChange(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value.length < 2) {
|
||||||
this.results = [];
|
this.results = [];
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.searchTask.perform();
|
this.searchTask.perform(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
searchTask = task({ restartable: true }, async () => {
|
searchTask = task({ restartable: true }, async (term) => {
|
||||||
await timeout(300);
|
await timeout(300);
|
||||||
|
|
||||||
if (this.query.length < 2) return;
|
const query = typeof term === 'string' ? term : this.query;
|
||||||
|
|
||||||
|
if (query.length < 2) return;
|
||||||
|
|
||||||
this.isLoading = true;
|
this.isLoading = true;
|
||||||
try {
|
try {
|
||||||
@@ -47,8 +64,20 @@ export default class SearchBoxComponent extends Component {
|
|||||||
if (this.mapUi.currentCenter) {
|
if (this.mapUi.currentCenter) {
|
||||||
({ lat, lon } = this.mapUi.currentCenter);
|
({ lat, lon } = this.mapUi.currentCenter);
|
||||||
}
|
}
|
||||||
const results = await this.photon.search(this.query, lat, lon);
|
|
||||||
this.results = results;
|
// Filter categories
|
||||||
|
const q = query.toLowerCase();
|
||||||
|
const categoryMatches = POI_CATEGORIES.filter((c) =>
|
||||||
|
c.label.toLowerCase().includes(q)
|
||||||
|
).map((c) => ({
|
||||||
|
source: 'category',
|
||||||
|
title: c.label,
|
||||||
|
id: c.id,
|
||||||
|
icon: 'search',
|
||||||
|
}));
|
||||||
|
|
||||||
|
const results = await this.photon.search(query, lat, lon);
|
||||||
|
this.results = [...categoryMatches, ...results];
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Search failed', e);
|
console.error('Search failed', e);
|
||||||
this.results = [];
|
this.results = [];
|
||||||
@@ -80,7 +109,7 @@ export default class SearchBoxComponent extends Component {
|
|||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (!this.query) return;
|
if (!this.query) return;
|
||||||
|
|
||||||
let queryParams = { q: this.query, selected: null };
|
let queryParams = { q: this.query, selected: null, category: null };
|
||||||
|
|
||||||
if (this.mapUi.currentCenter) {
|
if (this.mapUi.currentCenter) {
|
||||||
const { lat, lon } = this.mapUi.currentCenter;
|
const { lat, lon } = this.mapUi.currentCenter;
|
||||||
@@ -94,7 +123,37 @@ export default class SearchBoxComponent extends Component {
|
|||||||
|
|
||||||
@action
|
@action
|
||||||
selectResult(place) {
|
selectResult(place) {
|
||||||
|
if (place.source === 'category') {
|
||||||
|
this.query = place.title;
|
||||||
|
if (this.args.onQueryChange) {
|
||||||
|
this.args.onQueryChange(place.title);
|
||||||
|
}
|
||||||
|
this.results = [];
|
||||||
|
|
||||||
|
let lat = null,
|
||||||
|
lon = null;
|
||||||
|
if (this.mapUi.currentCenter) {
|
||||||
|
({ lat, lon } = this.mapUi.currentCenter);
|
||||||
|
lat = lat?.toString();
|
||||||
|
lon = lon?.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
this.router.transitionTo('search', {
|
||||||
|
queryParams: {
|
||||||
|
q: place.title,
|
||||||
|
category: place.id,
|
||||||
|
selected: null,
|
||||||
|
lat: lat,
|
||||||
|
lon: lon,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
this.query = place.title;
|
this.query = place.title;
|
||||||
|
if (this.args.onQueryChange) {
|
||||||
|
this.args.onQueryChange(place.title);
|
||||||
|
}
|
||||||
this.results = []; // Hide popover
|
this.results = []; // Hide popover
|
||||||
|
|
||||||
// If it has an OSM ID, go to place details
|
// If it has an OSM ID, go to place details
|
||||||
@@ -112,6 +171,7 @@ export default class SearchBoxComponent extends Component {
|
|||||||
lat: place.lat,
|
lat: place.lat,
|
||||||
lon: place.lon,
|
lon: place.lon,
|
||||||
selected: null,
|
selected: null,
|
||||||
|
category: null,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -119,10 +179,17 @@ export default class SearchBoxComponent extends Component {
|
|||||||
|
|
||||||
@action
|
@action
|
||||||
clear() {
|
clear() {
|
||||||
|
this.searchTask.cancelAll();
|
||||||
|
this.mapUi.stopLoading();
|
||||||
|
this.osm.cancelAll();
|
||||||
|
this.photon.cancelAll();
|
||||||
|
|
||||||
this.query = '';
|
this.query = '';
|
||||||
this.results = [];
|
this.results = [];
|
||||||
this.router.transitionTo('index'); // Or stay on current page?
|
if (this.args.onQueryChange) {
|
||||||
// Usually clear just clears the input.
|
this.args.onQueryChange('');
|
||||||
|
}
|
||||||
|
this.router.transitionTo('index');
|
||||||
}
|
}
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -150,7 +217,16 @@ export default class SearchBoxComponent extends Component {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<button type="submit" class="search-submit-btn" aria-label="Search">
|
<button type="submit" class="search-submit-btn" aria-label="Search">
|
||||||
<Icon @name="search" @size={{20}} @color="#5f6368" />
|
{{#if
|
||||||
|
(or
|
||||||
|
(eq this.mapUi.loadingState.type "text")
|
||||||
|
(eq this.mapUi.loadingState.type "category")
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
<Icon @name="loading-ring" @size={{20}} />
|
||||||
|
{{else}}
|
||||||
|
<Icon @name="search" @size={{20}} @color="#5f6368" />
|
||||||
|
{{/if}}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{{#if this.query}}
|
{{#if this.query}}
|
||||||
@@ -176,7 +252,11 @@ export default class SearchBoxComponent extends Component {
|
|||||||
{{on "click" (fn this.selectResult result)}}
|
{{on "click" (fn this.selectResult result)}}
|
||||||
>
|
>
|
||||||
<div class="result-icon">
|
<div class="result-icon">
|
||||||
<Icon @name="map-pin" @size={{16}} @color="#666" />
|
<Icon
|
||||||
|
@name={{if result.icon result.icon "map-pin"}}
|
||||||
|
@size={{16}}
|
||||||
|
@color="#666"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="result-info">
|
<div class="result-info">
|
||||||
<span class="result-title">{{result.title}}</span>
|
<span class="result-title">{{result.title}}</span>
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import Controller from '@ember/controller';
|
import Controller from '@ember/controller';
|
||||||
|
|
||||||
export default class SearchController extends Controller {
|
export default class SearchController extends Controller {
|
||||||
queryParams = ['lat', 'lon', 'q', 'selected'];
|
queryParams = ['lat', 'lon', 'q', 'selected', 'category'];
|
||||||
|
|
||||||
lat = null;
|
lat = null;
|
||||||
lon = null;
|
lon = null;
|
||||||
q = null;
|
q = null;
|
||||||
selected = null;
|
selected = null;
|
||||||
|
category = null;
|
||||||
}
|
}
|
||||||
|
|||||||
1
app/icons/270-ring.svg
Normal file
1
app/icons/270-ring.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" data-ember-extension="1"><path d="M10.72,19.9a8,8,0,0,1-6.5-9.79A7.77,7.77,0,0,1,10.4,4.16a8,8,0,0,1,9.49,6.52A1.54,1.54,0,0,0,21.38,12h.13a1.37,1.37,0,0,0,1.38-1.54,11,11,0,1,0-12.7,12.39A1.54,1.54,0,0,0,12,21.34h0A1.47,1.47,0,0,0,10.72,19.9Z"><animateTransform attributeName="transform" type="rotate" dur="0.75s" values="0 12 12;360 12 12" repeatCount="indefinite"/></path></svg>
|
||||||
|
After Width: | Height: | Size: 464 B |
10
app/routes/index.js
Normal file
10
app/routes/index.js
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,74 +15,126 @@ export default class SearchRoute extends Route {
|
|||||||
lon: { refreshModel: true },
|
lon: { refreshModel: true },
|
||||||
q: { refreshModel: true },
|
q: { refreshModel: true },
|
||||||
selected: { refreshModel: true },
|
selected: { refreshModel: true },
|
||||||
|
category: { refreshModel: true },
|
||||||
};
|
};
|
||||||
|
|
||||||
async model(params) {
|
async model(params) {
|
||||||
const lat = params.lat ? parseFloat(params.lat) : null;
|
const lat = params.lat ? parseFloat(params.lat) : null;
|
||||||
const lon = params.lon ? parseFloat(params.lon) : null;
|
const lon = params.lon ? parseFloat(params.lon) : null;
|
||||||
let pois = [];
|
let pois = [];
|
||||||
|
let loadingType = null;
|
||||||
|
let loadingValue = null;
|
||||||
|
|
||||||
// Case 1: Text Search (q parameter present)
|
try {
|
||||||
if (params.q) {
|
// Case 0: Category Search (category parameter present)
|
||||||
// Search with Photon (using lat/lon for bias if available)
|
if (params.category && lat && lon) {
|
||||||
pois = await this.photon.search(params.q, lat, lon);
|
loadingType = 'category';
|
||||||
|
loadingValue = params.category;
|
||||||
|
this.mapUi.startLoading(loadingType, loadingValue);
|
||||||
|
|
||||||
// Search local bookmarks by name
|
// We need bounds. If we have active map state, use it.
|
||||||
const queryLower = params.q.toLowerCase();
|
let bounds = this.mapUi.currentBounds;
|
||||||
const localMatches = this.storage.savedPlaces.filter((p) => {
|
|
||||||
return (
|
|
||||||
p.title?.toLowerCase().includes(queryLower) ||
|
|
||||||
p.description?.toLowerCase().includes(queryLower)
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Merge local matches
|
// If we don't have bounds (direct URL visit), estimate them from lat/lon/zoom(16)
|
||||||
localMatches.forEach((local) => {
|
// or just use a fixed box around the center.
|
||||||
const exists = pois.find(
|
if (!bounds) {
|
||||||
(poi) =>
|
// Approximate 0.01 degrees ~ 1km at equator. A viewport is roughly 0.02x0.01 at zoom 16?
|
||||||
(local.osmId && poi.osmId === local.osmId) ||
|
// Let's take a safe box of ~1km radius.
|
||||||
(poi.id && poi.id === local.id)
|
const delta = 0.01;
|
||||||
);
|
bounds = {
|
||||||
if (!exists) {
|
minLat: lat - delta,
|
||||||
pois.push(local);
|
maxLat: lat + delta,
|
||||||
|
minLon: lon - delta,
|
||||||
|
maxLon: lon + delta,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
});
|
|
||||||
}
|
|
||||||
// Case 2: Nearby Search (lat/lon present, no q)
|
|
||||||
else if (lat && lon) {
|
|
||||||
const searchRadius = 50; // Default radius
|
|
||||||
|
|
||||||
// Fetch POIs from Overpass
|
pois = await this.osm.getCategoryPois(
|
||||||
pois = await this.osm.getNearbyPois(lat, lon, searchRadius);
|
bounds,
|
||||||
|
params.category,
|
||||||
// Get cached/saved places in search radius
|
lat,
|
||||||
const localMatches = this.storage.savedPlaces.filter((p) => {
|
lon
|
||||||
const dist = getDistance(lat, lon, p.lat, p.lon);
|
|
||||||
return dist <= searchRadius;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Merge local matches
|
|
||||||
localMatches.forEach((local) => {
|
|
||||||
const exists = pois.find(
|
|
||||||
(poi) =>
|
|
||||||
(local.osmId && poi.osmId === local.osmId) ||
|
|
||||||
(poi.id && poi.id === local.id)
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!exists) {
|
// Sort by distance from center
|
||||||
pois.push(local);
|
pois = pois
|
||||||
}
|
.map((p) => ({
|
||||||
});
|
|
||||||
|
|
||||||
// Sort by distance from click
|
|
||||||
pois = pois
|
|
||||||
.map((p) => {
|
|
||||||
return {
|
|
||||||
...p,
|
...p,
|
||||||
_distance: getDistance(lat, lon, p.lat, p.lon),
|
_distance: getDistance(lat, lon, p.lat, p.lon),
|
||||||
};
|
}))
|
||||||
})
|
.sort((a, b) => a._distance - b._distance);
|
||||||
.sort((a, b) => a._distance - b._distance);
|
}
|
||||||
|
// Case 1: Text Search (q parameter present)
|
||||||
|
else if (params.q) {
|
||||||
|
loadingType = 'text';
|
||||||
|
loadingValue = params.q;
|
||||||
|
this.mapUi.startLoading(loadingType, loadingValue);
|
||||||
|
|
||||||
|
// Search with Photon (using lat/lon for bias if available)
|
||||||
|
pois = await this.photon.search(params.q, lat, lon);
|
||||||
|
|
||||||
|
// Search local bookmarks by name
|
||||||
|
const queryLower = params.q.toLowerCase();
|
||||||
|
const localMatches = this.storage.savedPlaces.filter((p) => {
|
||||||
|
return (
|
||||||
|
p.title?.toLowerCase().includes(queryLower) ||
|
||||||
|
p.description?.toLowerCase().includes(queryLower)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Merge local matches
|
||||||
|
localMatches.forEach((local) => {
|
||||||
|
const exists = pois.find(
|
||||||
|
(poi) =>
|
||||||
|
(local.osmId && poi.osmId === local.osmId) ||
|
||||||
|
(poi.id && poi.id === local.id)
|
||||||
|
);
|
||||||
|
if (!exists) {
|
||||||
|
pois.push(local);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Case 2: Nearby Search (lat/lon present, no q)
|
||||||
|
else if (lat && lon) {
|
||||||
|
// Nearby search does NOT trigger loading state (pulse is used instead)
|
||||||
|
const searchRadius = 50; // Default radius
|
||||||
|
|
||||||
|
// Fetch POIs from Overpass
|
||||||
|
pois = await this.osm.getNearbyPois(lat, lon, searchRadius);
|
||||||
|
|
||||||
|
// Get cached/saved places in search radius
|
||||||
|
const localMatches = this.storage.savedPlaces.filter((p) => {
|
||||||
|
const dist = getDistance(lat, lon, p.lat, p.lon);
|
||||||
|
return dist <= searchRadius;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Merge local matches
|
||||||
|
localMatches.forEach((local) => {
|
||||||
|
const exists = pois.find(
|
||||||
|
(poi) =>
|
||||||
|
(local.osmId && poi.osmId === local.osmId) ||
|
||||||
|
(poi.id && poi.id === local.id)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!exists) {
|
||||||
|
pois.push(local);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sort by distance from click
|
||||||
|
pois = pois
|
||||||
|
.map((p) => {
|
||||||
|
return {
|
||||||
|
...p,
|
||||||
|
_distance: getDistance(lat, lon, p.lat, p.lon),
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.sort((a, b) => a._distance - b._distance);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (loadingType && loadingValue) {
|
||||||
|
this.mapUi.stopLoading(loadingType, loadingValue);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if any of these are already bookmarked
|
// Check if any of these are already bookmarked
|
||||||
@@ -139,6 +191,11 @@ 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);
|
||||||
|
|
||||||
|
// Store current search params to allow "Up" navigation from place details
|
||||||
|
const { q, category, lat, lon } = this.paramsFor('search');
|
||||||
|
this.mapUi.currentSearch = { q, category, lat, lon };
|
||||||
}
|
}
|
||||||
|
|
||||||
@action
|
@action
|
||||||
|
|||||||
@@ -8,9 +8,13 @@ export default class MapUiService extends Service {
|
|||||||
@tracked creationCoordinates = null;
|
@tracked creationCoordinates = null;
|
||||||
@tracked returnToSearch = false;
|
@tracked returnToSearch = false;
|
||||||
@tracked currentCenter = null;
|
@tracked currentCenter = null;
|
||||||
|
@tracked currentBounds = null;
|
||||||
@tracked searchBoxHasFocus = false;
|
@tracked searchBoxHasFocus = false;
|
||||||
@tracked selectionOptions = {};
|
@tracked selectionOptions = {};
|
||||||
@tracked preventNextZoom = false;
|
@tracked preventNextZoom = false;
|
||||||
|
@tracked searchResults = [];
|
||||||
|
@tracked currentSearch = null;
|
||||||
|
@tracked loadingState = null;
|
||||||
|
|
||||||
selectPlace(place, options = {}) {
|
selectPlace(place, options = {}) {
|
||||||
this.selectedPlace = place;
|
this.selectedPlace = place;
|
||||||
@@ -23,6 +27,15 @@ export default class MapUiService extends Service {
|
|||||||
this.preventNextZoom = false;
|
this.preventNextZoom = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setSearchResults(results) {
|
||||||
|
this.searchResults = results || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
clearSearchResults() {
|
||||||
|
this.searchResults = [];
|
||||||
|
this.currentSearch = null;
|
||||||
|
}
|
||||||
|
|
||||||
startSearch() {
|
startSearch() {
|
||||||
this.isSearching = true;
|
this.isSearching = true;
|
||||||
this.isCreating = false;
|
this.isCreating = false;
|
||||||
@@ -54,4 +67,30 @@ export default class MapUiService extends Service {
|
|||||||
updateCenter(lat, lon) {
|
updateCenter(lat, lon) {
|
||||||
this.currentCenter = { lat, lon };
|
this.currentCenter = { lat, lon };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
updateBounds(bounds) {
|
||||||
|
this.currentBounds = bounds;
|
||||||
|
}
|
||||||
|
|
||||||
|
startLoading(type, value) {
|
||||||
|
this.loadingState = { type, value };
|
||||||
|
}
|
||||||
|
|
||||||
|
stopLoading(type = null, value = null) {
|
||||||
|
// If no arguments provided, force stop (legacy/cleanup)
|
||||||
|
if (!type && !value) {
|
||||||
|
this.loadingState = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only clear if the current state matches the request
|
||||||
|
// This prevents a previous search from clearing the state of a new search
|
||||||
|
if (
|
||||||
|
this.loadingState &&
|
||||||
|
this.loadingState.type === type &&
|
||||||
|
this.loadingState.value === value
|
||||||
|
) {
|
||||||
|
this.loadingState = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import Service, { service } from '@ember/service';
|
import Service, { service } from '@ember/service';
|
||||||
import { getLocalizedName, getPlaceType } from '../utils/osm';
|
import { getLocalizedName, getPlaceType } from '../utils/osm';
|
||||||
|
import { getCategoryById } from '../utils/poi-categories';
|
||||||
|
|
||||||
export default class OsmService extends Service {
|
export default class OsmService extends Service {
|
||||||
@service settings;
|
@service settings;
|
||||||
@@ -8,6 +9,13 @@ export default class OsmService extends Service {
|
|||||||
cachedResults = null;
|
cachedResults = null;
|
||||||
lastQueryKey = null;
|
lastQueryKey = null;
|
||||||
|
|
||||||
|
cancelAll() {
|
||||||
|
if (this.controller) {
|
||||||
|
this.controller.abort();
|
||||||
|
this.controller = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async getNearbyPois(lat, lon, radius = 50) {
|
async getNearbyPois(lat, lon, radius = 50) {
|
||||||
const queryKey = `${lat},${lon},${radius}`;
|
const queryKey = `${lat},${lon},${radius}`;
|
||||||
|
|
||||||
@@ -39,15 +47,26 @@ export default class OsmService extends Service {
|
|||||||
];
|
];
|
||||||
const typeKeysQuery = [`~"^(${typeKeys.join('|')})$"~".*"`];
|
const typeKeysQuery = [`~"^(${typeKeys.join('|')})$"~".*"`];
|
||||||
|
|
||||||
|
const negativeFilters = {
|
||||||
|
public_transport: ['stop_area', 'platform'],
|
||||||
|
};
|
||||||
|
|
||||||
|
const negativeFiltersQuery = Object.entries(negativeFilters)
|
||||||
|
.map(([key, values]) => {
|
||||||
|
const valueRegex = `^(${values.join('|')})$`;
|
||||||
|
return `["${key}"!~"${valueRegex}"]`;
|
||||||
|
})
|
||||||
|
.join('');
|
||||||
|
|
||||||
const query = `
|
const query = `
|
||||||
[out:json][timeout:25];
|
[out:json][timeout:25];
|
||||||
(
|
(
|
||||||
node(around:${radius},${lat},${lon})
|
node(around:${radius},${lat},${lon})
|
||||||
[${typeKeysQuery}][~"^name"~"."];
|
[${typeKeysQuery}]${negativeFiltersQuery}[~"^name"~"."];
|
||||||
way(around:${radius},${lat},${lon})
|
way(around:${radius},${lat},${lon})
|
||||||
[${typeKeysQuery}][~"^name"~"."];
|
[${typeKeysQuery}]${negativeFiltersQuery}[~"^name"~"."];
|
||||||
relation(around:${radius},${lat},${lon})
|
relation(around:${radius},${lat},${lon})
|
||||||
[${typeKeysQuery}][~"^name"~"."];
|
[${typeKeysQuery}]${negativeFiltersQuery}[~"^name"~"."];
|
||||||
);
|
);
|
||||||
out center;
|
out center;
|
||||||
`.trim();
|
`.trim();
|
||||||
@@ -76,6 +95,70 @@ out center;
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getCategoryPois(bounds, categoryId, lat, lon) {
|
||||||
|
const category = getCategoryById(categoryId);
|
||||||
|
if (!category || !bounds) return [];
|
||||||
|
|
||||||
|
const queryKey = lat && lon ? `cat:${categoryId}:${lat}:${lon}` : null;
|
||||||
|
|
||||||
|
if (queryKey && this.lastQueryKey === queryKey && this.cachedResults) {
|
||||||
|
console.debug('Returning cached category results for:', queryKey);
|
||||||
|
return this.cachedResults;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.controller) {
|
||||||
|
this.controller.abort();
|
||||||
|
}
|
||||||
|
this.controller = new AbortController();
|
||||||
|
const signal = this.controller.signal;
|
||||||
|
|
||||||
|
const { minLat, minLon, maxLat, maxLon } = bounds;
|
||||||
|
|
||||||
|
// Build the query parts for each filter string and type
|
||||||
|
const queryParts = [];
|
||||||
|
|
||||||
|
// Default types if not specified (legacy fallback)
|
||||||
|
const types = category.types || ['node', 'way', 'relation'];
|
||||||
|
|
||||||
|
category.filter.forEach((filterString) => {
|
||||||
|
types.forEach((type) => {
|
||||||
|
// We ensure we only fetch named POIs to reduce noise
|
||||||
|
queryParts.push(`${type}${filterString}[~"^name"~"."];`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const query = `
|
||||||
|
[out:json][timeout:25][bbox:${minLat},${minLon},${maxLat},${maxLon}];
|
||||||
|
(
|
||||||
|
${queryParts.join('\n ')}
|
||||||
|
);
|
||||||
|
out center;
|
||||||
|
`.trim();
|
||||||
|
|
||||||
|
const url = `${this.settings.overpassApi}?data=${encodeURIComponent(query)}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await this.fetchWithRetry(url, { signal });
|
||||||
|
if (!res.ok) throw new Error('Overpass request failed');
|
||||||
|
const data = await res.json();
|
||||||
|
const results = data.elements.map(this.normalizePoi);
|
||||||
|
|
||||||
|
if (queryKey) {
|
||||||
|
this.lastQueryKey = queryKey;
|
||||||
|
this.cachedResults = results;
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
} catch (e) {
|
||||||
|
if (e.name === 'AbortError') {
|
||||||
|
console.debug('Category search aborted');
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
console.error('Category search failed', e);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
normalizePoi(poi) {
|
normalizePoi(poi) {
|
||||||
const tags = poi.tags || {};
|
const tags = poi.tags || {};
|
||||||
const type = getPlaceType(tags) || 'Point of Interest';
|
const type = getPlaceType(tags) || 'Point of Interest';
|
||||||
|
|||||||
@@ -5,6 +5,15 @@ import { humanizeOsmTag } from '../utils/format-text';
|
|||||||
export default class PhotonService extends Service {
|
export default class PhotonService extends Service {
|
||||||
@service settings;
|
@service settings;
|
||||||
|
|
||||||
|
controller = null;
|
||||||
|
|
||||||
|
cancelAll() {
|
||||||
|
if (this.controller) {
|
||||||
|
this.controller.abort();
|
||||||
|
this.controller = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
get baseUrl() {
|
get baseUrl() {
|
||||||
return this.settings.photonApi;
|
return this.settings.photonApi;
|
||||||
}
|
}
|
||||||
@@ -12,6 +21,12 @@ export default class PhotonService extends Service {
|
|||||||
async search(query, lat, lon, limit = 10) {
|
async search(query, lat, lon, limit = 10) {
|
||||||
if (!query || query.length < 2) return [];
|
if (!query || query.length < 2) return [];
|
||||||
|
|
||||||
|
if (this.controller) {
|
||||||
|
this.controller.abort();
|
||||||
|
}
|
||||||
|
this.controller = new AbortController();
|
||||||
|
const signal = this.controller.signal;
|
||||||
|
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
q: query,
|
q: query,
|
||||||
limit: String(limit),
|
limit: String(limit),
|
||||||
@@ -25,7 +40,7 @@ export default class PhotonService extends Service {
|
|||||||
const url = `${this.baseUrl}?${params.toString()}`;
|
const url = `${this.baseUrl}?${params.toString()}`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await this.fetchWithRetry(url);
|
const res = await this.fetchWithRetry(url, { signal });
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
throw new Error(`Photon request failed with status ${res.status}`);
|
throw new Error(`Photon request failed with status ${res.status}`);
|
||||||
}
|
}
|
||||||
@@ -35,6 +50,9 @@ export default class PhotonService extends Service {
|
|||||||
|
|
||||||
return data.features.map((f) => this.normalizeFeature(f));
|
return data.features.map((f) => this.normalizeFeature(f));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
if (e.name === 'AbortError') {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
console.error('Photon search error:', e);
|
console.error('Photon search error:', e);
|
||||||
// Return empty array on error so UI doesn't break
|
// Return empty array on error so UI doesn't break
|
||||||
return [];
|
return [];
|
||||||
|
|||||||
@@ -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,
|
||||||
@@ -70,27 +72,96 @@ body {
|
|||||||
right: 0;
|
right: 0;
|
||||||
height: 60px;
|
height: 60px;
|
||||||
padding: 0.5rem 1rem;
|
padding: 0.5rem 1rem;
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
z-index: 3000; /* Above sidebar (2000) and map */
|
z-index: 3000; /* Above sidebar (2000) and map */
|
||||||
pointer-events: none; /* Let clicks pass through to map where transparent */
|
pointer-events: none; /* Let clicks pass through to map where transparent */
|
||||||
|
|
||||||
|
/* Layout */
|
||||||
|
display: grid;
|
||||||
|
|
||||||
|
/* Desktop: 1fr auto 1fr ensures the center element is absolutely centered */
|
||||||
|
grid-template-columns: 1fr auto 1fr;
|
||||||
|
grid-template-areas: 'search chips user';
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (width <= 768px) {
|
@media (width <= 768px) {
|
||||||
.app-header {
|
.app-header {
|
||||||
padding: 0 0.5rem;
|
padding: 0.5rem 0.5rem 0;
|
||||||
|
height: auto;
|
||||||
|
grid-template-columns: 1fr auto;
|
||||||
|
grid-template-areas:
|
||||||
|
'search user'
|
||||||
|
'chips chips';
|
||||||
|
row-gap: 8px; /* Increased spacing */
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-left,
|
.header-left,
|
||||||
.header-right {
|
.header-right,
|
||||||
pointer-events: auto; /* Re-enable clicks for buttons */
|
.header-center {
|
||||||
|
pointer-events: auto; /* Re-enable clicks */
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-left {
|
.header-left {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
grid-area: search;
|
||||||
|
|
||||||
|
/* Ensure it sits at the start of its grid area */
|
||||||
|
justify-self: start;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (width > 768px) {
|
||||||
|
.header-left {
|
||||||
|
min-width: 300px;
|
||||||
|
max-width: 400px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (width > 768px) {
|
||||||
|
.header-left {
|
||||||
|
/* Desktop: Ensure minimum width for search box so it's not squeezed */
|
||||||
|
min-width: 300px;
|
||||||
|
max-width: 350px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-right {
|
||||||
|
grid-area: user;
|
||||||
|
justify-self: end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-center {
|
||||||
|
grid-area: chips;
|
||||||
|
|
||||||
|
/* Desktop: Center the chips block in the available space */
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 0; /* Allow shrinking */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Adjust scroll container for desktop centering */
|
||||||
|
@media (width > 768px) {
|
||||||
|
.header-center .category-chips-scroll {
|
||||||
|
width: auto;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (width <= 768px) {
|
||||||
|
/* No need to reset min-width/max-width since they are only set in media query above */
|
||||||
|
.header-center {
|
||||||
|
width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
justify-content: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hide chips on mobile when searching to save space */
|
||||||
|
.header-center.searching {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-press {
|
.btn-press {
|
||||||
@@ -803,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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1190,3 +1261,63 @@ button.create-place {
|
|||||||
background: #eee;
|
background: #eee;
|
||||||
margin: 0.5rem 0;
|
margin: 0.5rem 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Category Chips */
|
||||||
|
.category-chips-scroll {
|
||||||
|
width: 100%;
|
||||||
|
overflow-x: auto;
|
||||||
|
|
||||||
|
/* Add padding for shadows */
|
||||||
|
padding: 4px 0;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
|
||||||
|
/* Hide scrollbar but keep functionality */
|
||||||
|
scrollbar-width: none; /* Firefox */
|
||||||
|
-ms-overflow-style: none; /* IE/Edge */
|
||||||
|
|
||||||
|
/* Remove top margin as spacing is handled by grid/layout */
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-chips-scroll::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-chips-container {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
|
||||||
|
/* Padding on sides so first/last chip isn't flush with screen edge */
|
||||||
|
padding: 0 4px;
|
||||||
|
width: max-content; /* Ensure it scrolls */
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-chip {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
background: white;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 16px; /* Pill shape */
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: #333;
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
box-shadow: 0 1px 3px rgb(0 0 0 / 10%);
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-chip:hover {
|
||||||
|
background: var(--hover-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-chip:active {
|
||||||
|
background: #eee;
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-chip:disabled {
|
||||||
|
opacity: 0.75;
|
||||||
|
cursor: not-allowed;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|||||||
@@ -77,9 +77,11 @@ export default class PlaceTemplate extends Component {
|
|||||||
navigateBack(place) {
|
navigateBack(place) {
|
||||||
// The sidebar calls this with null when "Back" is clicked.
|
// The sidebar calls this with null when "Back" is clicked.
|
||||||
if (place === null) {
|
if (place === null) {
|
||||||
// If we came from search results, go back in history
|
// If we have an active search context, return to it (UP navigation)
|
||||||
if (this.mapUi.returnToSearch) {
|
if (this.mapUi.returnToSearch && this.mapUi.currentSearch) {
|
||||||
window.history.back();
|
this.router.transitionTo('search', {
|
||||||
|
queryParams: this.mapUi.currentSearch,
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
// Otherwise just close the sidebar (return to map index)
|
// Otherwise just close the sidebar (return to map index)
|
||||||
this.router.transitionTo('index');
|
this.router.transitionTo('index');
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ export default class SearchTemplate extends Component {
|
|||||||
selectPlace(place) {
|
selectPlace(place) {
|
||||||
if (place) {
|
if (place) {
|
||||||
this.mapUi.returnToSearch = true;
|
this.mapUi.returnToSearch = true;
|
||||||
|
// We don't need to manually set currentSearch here because
|
||||||
|
// it was already set in the route's setupController
|
||||||
this.router.transitionTo('place', place);
|
this.router.transitionTo('place', place);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
import arrowLeft from 'feather-icons/dist/icons/arrow-left.svg?raw';
|
|
||||||
import activity from 'feather-icons/dist/icons/activity.svg?raw';
|
import activity from 'feather-icons/dist/icons/activity.svg?raw';
|
||||||
|
import arrowLeft from 'feather-icons/dist/icons/arrow-left.svg?raw';
|
||||||
import bookmark from 'feather-icons/dist/icons/bookmark.svg?raw';
|
import bookmark from 'feather-icons/dist/icons/bookmark.svg?raw';
|
||||||
import checkSquare from 'feather-icons/dist/icons/check-square.svg?raw';
|
import checkSquare from 'feather-icons/dist/icons/check-square.svg?raw';
|
||||||
|
import cigaretteWithSmokeCurl from '@waysidemapping/pinhead/dist/icons/cigarette_with_smoke_curl.svg?raw';
|
||||||
import clock from 'feather-icons/dist/icons/clock.svg?raw';
|
import clock from 'feather-icons/dist/icons/clock.svg?raw';
|
||||||
import edit from 'feather-icons/dist/icons/edit.svg?raw';
|
import edit from 'feather-icons/dist/icons/edit.svg?raw';
|
||||||
|
import eyeglasses from '@waysidemapping/pinhead/dist/icons/eyeglasses.svg?raw';
|
||||||
import facebook from 'feather-icons/dist/icons/facebook.svg?raw';
|
import facebook from 'feather-icons/dist/icons/facebook.svg?raw';
|
||||||
|
import fancyMirrorWithReflectionAndStars from '@waysidemapping/pinhead/dist/icons/fancy_mirror_with_reflection_and_stars.svg?raw';
|
||||||
|
import familyRestroomSymbol from '@waysidemapping/pinhead/dist/icons/family_restroom_symbol.svg?raw';
|
||||||
import gift from 'feather-icons/dist/icons/gift.svg?raw';
|
import gift from 'feather-icons/dist/icons/gift.svg?raw';
|
||||||
import globe from 'feather-icons/dist/icons/globe.svg?raw';
|
import globe from 'feather-icons/dist/icons/globe.svg?raw';
|
||||||
import heart from 'feather-icons/dist/icons/heart.svg?raw';
|
import heart from 'feather-icons/dist/icons/heart.svg?raw';
|
||||||
@@ -13,57 +17,221 @@ import info from 'feather-icons/dist/icons/info.svg?raw';
|
|||||||
import instagram from 'feather-icons/dist/icons/instagram.svg?raw';
|
import instagram from 'feather-icons/dist/icons/instagram.svg?raw';
|
||||||
import logIn from 'feather-icons/dist/icons/log-in.svg?raw';
|
import logIn from 'feather-icons/dist/icons/log-in.svg?raw';
|
||||||
import logOut from 'feather-icons/dist/icons/log-out.svg?raw';
|
import logOut from 'feather-icons/dist/icons/log-out.svg?raw';
|
||||||
|
import lowriseBuilding from '@waysidemapping/pinhead/dist/icons/lowrise_building.svg?raw';
|
||||||
import mail from 'feather-icons/dist/icons/mail.svg?raw';
|
import mail from 'feather-icons/dist/icons/mail.svg?raw';
|
||||||
import map from 'feather-icons/dist/icons/map.svg?raw';
|
import map from 'feather-icons/dist/icons/map.svg?raw';
|
||||||
import mapPin from 'feather-icons/dist/icons/map-pin.svg?raw';
|
import mapPin from 'feather-icons/dist/icons/map-pin.svg?raw';
|
||||||
import menu from 'feather-icons/dist/icons/menu.svg?raw';
|
import menu from 'feather-icons/dist/icons/menu.svg?raw';
|
||||||
import navigation from 'feather-icons/dist/icons/navigation.svg?raw';
|
import navigation from 'feather-icons/dist/icons/navigation.svg?raw';
|
||||||
|
import needleAndSpoolOfThread from '@waysidemapping/pinhead/dist/icons/needle_and_spool_of_thread.svg?raw';
|
||||||
import phone from 'feather-icons/dist/icons/phone.svg?raw';
|
import phone from 'feather-icons/dist/icons/phone.svg?raw';
|
||||||
import plus from 'feather-icons/dist/icons/plus.svg?raw';
|
import plus from 'feather-icons/dist/icons/plus.svg?raw';
|
||||||
import server from 'feather-icons/dist/icons/server.svg?raw';
|
|
||||||
import search from 'feather-icons/dist/icons/search.svg?raw';
|
import search from 'feather-icons/dist/icons/search.svg?raw';
|
||||||
|
import server from 'feather-icons/dist/icons/server.svg?raw';
|
||||||
import settings from 'feather-icons/dist/icons/settings.svg?raw';
|
import settings from 'feather-icons/dist/icons/settings.svg?raw';
|
||||||
import target from 'feather-icons/dist/icons/target.svg?raw';
|
import target from 'feather-icons/dist/icons/target.svg?raw';
|
||||||
import user from 'feather-icons/dist/icons/user.svg?raw';
|
import user from 'feather-icons/dist/icons/user.svg?raw';
|
||||||
|
import villageBuildings from '@waysidemapping/pinhead/dist/icons/village_buildings.svg?raw';
|
||||||
import x from 'feather-icons/dist/icons/x.svg?raw';
|
import x from 'feather-icons/dist/icons/x.svg?raw';
|
||||||
import zap from 'feather-icons/dist/icons/zap.svg?raw';
|
import zap from 'feather-icons/dist/icons/zap.svg?raw';
|
||||||
import wikipedia from '../icons/wikipedia.svg?raw';
|
import loadingRing from '../icons/270-ring.svg?raw';
|
||||||
|
|
||||||
|
import angelfish from '@waysidemapping/pinhead/dist/icons/angelfish.svg?raw';
|
||||||
|
import barbell from '@waysidemapping/pinhead/dist/icons/barbell.svg?raw';
|
||||||
|
import banknote from '@waysidemapping/pinhead/dist/icons/banknote.svg?raw';
|
||||||
|
import badgeShieldWithFire from '@waysidemapping/pinhead/dist/icons/badge_shield_with_fire.svg?raw';
|
||||||
|
import beachUmbrellaInGround from '@waysidemapping/pinhead/dist/icons/beach_umbrella_in_ground.svg?raw';
|
||||||
|
import beerMugWithFoam from '@waysidemapping/pinhead/dist/icons/beer_mug_with_foam.svg?raw';
|
||||||
|
import burgerAndDrinkCupWithStraw from '@waysidemapping/pinhead/dist/icons/burger_and_drink_cup_with_straw.svg?raw';
|
||||||
|
import bus from '@waysidemapping/pinhead/dist/icons/bus.svg?raw';
|
||||||
|
import boxingGloveUp from '@waysidemapping/pinhead/dist/icons/boxing_glove_up.svg?raw';
|
||||||
|
import camera from '@waysidemapping/pinhead/dist/icons/camera.svg?raw';
|
||||||
|
import classicalBuilding from '@waysidemapping/pinhead/dist/icons/classical_building.svg?raw';
|
||||||
|
import classicalBuildingWithDomeAndFlag from '@waysidemapping/pinhead/dist/icons/classical_building_with_dome_and_flag.svg?raw';
|
||||||
|
import classicalBuildingWithFlag from '@waysidemapping/pinhead/dist/icons/classical_building_with_flag.svg?raw';
|
||||||
|
import commercialBuilding from '@waysidemapping/pinhead/dist/icons/commercial_building.svg?raw';
|
||||||
|
import clothesHanger from '@waysidemapping/pinhead/dist/icons/clothes_hanger.svg?raw';
|
||||||
|
import cleaver from '@waysidemapping/pinhead/dist/icons/cleaver.svg?raw';
|
||||||
|
import cloth from '@waysidemapping/pinhead/dist/icons/cloth.svg?raw';
|
||||||
|
import cocktail from '@waysidemapping/pinhead/dist/icons/cocktail.svg?raw';
|
||||||
|
import coffeeBean from '@waysidemapping/pinhead/dist/icons/coffee_bean.svg?raw';
|
||||||
|
import comedyMaskAndTragedyMask from '@waysidemapping/pinhead/dist/icons/comedy_mask_and_tragedy_mask.svg?raw';
|
||||||
|
import croissant from '@waysidemapping/pinhead/dist/icons/croissant.svg?raw';
|
||||||
|
import cupAndSaucer from '@waysidemapping/pinhead/dist/icons/cup_and_saucer.svg?raw';
|
||||||
|
import donut from '@waysidemapping/pinhead/dist/icons/donut.svg?raw';
|
||||||
|
import film from '@waysidemapping/pinhead/dist/icons/film.svg?raw';
|
||||||
|
import fingernailPolished from '@waysidemapping/pinhead/dist/icons/fingernail_polished.svg?raw';
|
||||||
|
import fish from '@waysidemapping/pinhead/dist/icons/fish.svg?raw';
|
||||||
|
import flagCheckered from '@waysidemapping/pinhead/dist/icons/flag_checkered.svg?raw';
|
||||||
|
import flowerBouquet from '@waysidemapping/pinhead/dist/icons/flower_bouquet.svg?raw';
|
||||||
|
import fort from '@waysidemapping/pinhead/dist/icons/fort.svg?raw';
|
||||||
import forkAndKnife from '@waysidemapping/pinhead/dist/icons/fork_and_knife.svg?raw';
|
import forkAndKnife from '@waysidemapping/pinhead/dist/icons/fork_and_knife.svg?raw';
|
||||||
|
import gravestone from '@waysidemapping/pinhead/dist/icons/gravestone.svg?raw';
|
||||||
|
import grecianVase from '@waysidemapping/pinhead/dist/icons/grecian_vase.svg?raw';
|
||||||
|
import greekCross from '@waysidemapping/pinhead/dist/icons/greek_cross.svg?raw';
|
||||||
|
import iceCreamOnCone from '@waysidemapping/pinhead/dist/icons/ice_cream_on_cone.svg?raw';
|
||||||
|
import jewel from '@waysidemapping/pinhead/dist/icons/jewel.svg?raw';
|
||||||
|
import marketStall from '@waysidemapping/pinhead/dist/icons/market_stall.svg?raw';
|
||||||
|
import memorialStoneWithInscription from '@waysidemapping/pinhead/dist/icons/memorial_stone_with_inscription.svg?raw';
|
||||||
|
import mobilePhoneWithKeypadAndAntenna from '@waysidemapping/pinhead/dist/icons/mobile_phone_with_keypad_and_antenna.svg?raw';
|
||||||
|
import molarTooth from '@waysidemapping/pinhead/dist/icons/molar_tooth.svg?raw';
|
||||||
|
import openBook from '@waysidemapping/pinhead/dist/icons/open_book.svg?raw';
|
||||||
|
import palace from '@waysidemapping/pinhead/dist/icons/palace.svg?raw';
|
||||||
|
import personCricketBattingAtCricketBall from '@waysidemapping/pinhead/dist/icons/person_cricket_batting_at_cricket_ball.svg?raw';
|
||||||
|
import personBoardingTramWithDestinationDisplayAndPantographOnTramTrack from '@waysidemapping/pinhead/dist/icons/person_boarding_tram_with_destination_display_and_pantograph_on_tram_track.svg?raw';
|
||||||
|
import personJockeyingRacehorse from '@waysidemapping/pinhead/dist/icons/person_jockeying_racehorse.svg?raw';
|
||||||
|
import personPlayingTennis from '@waysidemapping/pinhead/dist/icons/person_playing_tennis.svg?raw';
|
||||||
|
import personRunning from '@waysidemapping/pinhead/dist/icons/person_running.svg?raw';
|
||||||
|
import personSleepingInBed from '@waysidemapping/pinhead/dist/icons/person_sleeping_in_bed.svg?raw';
|
||||||
|
import personSwimmingInWater from '@waysidemapping/pinhead/dist/icons/person_swimming_in_water.svg?raw';
|
||||||
|
import personSwingingGolfClub from '@waysidemapping/pinhead/dist/icons/person_swinging_golf_club.svg?raw';
|
||||||
|
import plantInRaisedPlanter from '@waysidemapping/pinhead/dist/icons/plant_in_raised_planter.svg?raw';
|
||||||
|
import placeOfWorshipBuilding from '@waysidemapping/pinhead/dist/icons/place_of_worship_building.svg?raw';
|
||||||
|
import playStructureWithSlide from '@waysidemapping/pinhead/dist/icons/play_structure_with_slide.svg?raw';
|
||||||
|
import policeOfficerWithStopArm from '@waysidemapping/pinhead/dist/icons/police_officer_with_stop_arm.svg?raw';
|
||||||
|
import planeTopRight from '@waysidemapping/pinhead/dist/icons/plane_top_right.svg?raw';
|
||||||
|
import roundStructureWithFlag from '@waysidemapping/pinhead/dist/icons/round_structure_with_flag.svg?raw';
|
||||||
|
import sailingShipInWater from '@waysidemapping/pinhead/dist/icons/sailing_ship_in_water.svg?raw';
|
||||||
|
import scissorsOpen from '@waysidemapping/pinhead/dist/icons/scissors_open.svg?raw';
|
||||||
|
import shipwreckInWater from '@waysidemapping/pinhead/dist/icons/shipwreck_in_water.svg?raw';
|
||||||
|
import shoppingBag from '@waysidemapping/pinhead/dist/icons/shopping_bag.svg?raw';
|
||||||
|
import shoppingBasket from '@waysidemapping/pinhead/dist/icons/shopping_basket.svg?raw';
|
||||||
|
import tableTennisPaddle from '@waysidemapping/pinhead/dist/icons/table_tennis_paddle.svg?raw';
|
||||||
|
import tattooMachine from '@waysidemapping/pinhead/dist/icons/tattoo_machine.svg?raw';
|
||||||
|
import toolbox from '@waysidemapping/pinhead/dist/icons/toolbox.svg?raw';
|
||||||
|
import treeAndBenchWithBackrest from '@waysidemapping/pinhead/dist/icons/tree_and_bench_with_backrest.svg?raw';
|
||||||
|
import shoppingCart from '@waysidemapping/pinhead/dist/icons/shopping_cart.svg?raw';
|
||||||
|
import wallHangingWithMountainsAndSun from '@waysidemapping/pinhead/dist/icons/wall_hanging_with_mountains_and_sun.svg?raw';
|
||||||
|
import womensAndMensRestroomSymbol from '@waysidemapping/pinhead/dist/icons/womens_and_mens_restroom_symbol.svg?raw';
|
||||||
|
|
||||||
|
import wikipedia from '../icons/wikipedia.svg?raw';
|
||||||
|
import parkingP from '@waysidemapping/pinhead/dist/icons/parking_p.svg?raw';
|
||||||
|
|
||||||
const ICONS = {
|
const ICONS = {
|
||||||
'arrow-left': arrowLeft,
|
|
||||||
activity,
|
activity,
|
||||||
|
angelfish,
|
||||||
|
'arrow-left': arrowLeft,
|
||||||
|
barbell,
|
||||||
|
banknote,
|
||||||
|
'badge-shield-with-fire': badgeShieldWithFire,
|
||||||
|
'beach-umbrella-in-ground': beachUmbrellaInGround,
|
||||||
|
'beer-mug-with-foam': beerMugWithFoam,
|
||||||
bookmark,
|
bookmark,
|
||||||
|
'boxing-glove-up': boxingGloveUp,
|
||||||
|
'burger-and-drink-cup-with-straw': burgerAndDrinkCupWithStraw,
|
||||||
|
bus,
|
||||||
|
camera,
|
||||||
'check-square': checkSquare,
|
'check-square': checkSquare,
|
||||||
|
'cigarette-with-smoke-curl': cigaretteWithSmokeCurl,
|
||||||
|
'classical-building': classicalBuilding,
|
||||||
|
'classical-building-with-dome-and-flag': classicalBuildingWithDomeAndFlag,
|
||||||
|
'classical-building-with-flag': classicalBuildingWithFlag,
|
||||||
|
'commercial-building': commercialBuilding,
|
||||||
|
'clothes-hanger': clothesHanger,
|
||||||
|
cleaver,
|
||||||
|
cloth,
|
||||||
|
cocktail,
|
||||||
clock,
|
clock,
|
||||||
|
'coffee-bean': coffeeBean,
|
||||||
|
'comedy-mask-and-tragedy-mask': comedyMaskAndTragedyMask,
|
||||||
|
croissant,
|
||||||
|
'cup-and-saucer': cupAndSaucer,
|
||||||
|
donut,
|
||||||
edit,
|
edit,
|
||||||
|
eyeglasses,
|
||||||
facebook,
|
facebook,
|
||||||
|
'fancy-mirror-with-reflection-and-stars': fancyMirrorWithReflectionAndStars,
|
||||||
|
'family-restroom-symbol': familyRestroomSymbol,
|
||||||
|
film,
|
||||||
|
'fingernail-polished': fingernailPolished,
|
||||||
|
fish,
|
||||||
|
'flag-checkered': flagCheckered,
|
||||||
|
'flower-bouquet': flowerBouquet,
|
||||||
|
'fork-and-knife': forkAndKnife,
|
||||||
|
fort,
|
||||||
gift,
|
gift,
|
||||||
globe,
|
globe,
|
||||||
|
gravestone,
|
||||||
|
'grecian-vase': grecianVase,
|
||||||
|
'greek-cross': greekCross,
|
||||||
heart,
|
heart,
|
||||||
home,
|
home,
|
||||||
|
'ice-cream-on-cone': iceCreamOnCone,
|
||||||
info,
|
info,
|
||||||
instagram,
|
instagram,
|
||||||
'fork-and-knife': forkAndKnife,
|
jewel,
|
||||||
'log-in': logIn,
|
'log-in': logIn,
|
||||||
'log-out': logOut,
|
'log-out': logOut,
|
||||||
|
'lowrise-building': lowriseBuilding,
|
||||||
mail,
|
mail,
|
||||||
map,
|
map,
|
||||||
'map-pin': mapPin,
|
'map-pin': mapPin,
|
||||||
|
'market-stall': marketStall,
|
||||||
|
'memorial-stone-with-inscription': memorialStoneWithInscription,
|
||||||
menu,
|
menu,
|
||||||
|
'mobile-phone-with-keypad-and-antenna': mobilePhoneWithKeypadAndAntenna,
|
||||||
|
'molar-tooth': molarTooth,
|
||||||
navigation,
|
navigation,
|
||||||
|
'needle-and-spool-of-thread': needleAndSpoolOfThread,
|
||||||
|
'open-book': openBook,
|
||||||
|
palace,
|
||||||
|
'person-cricket-batting-at-cricket-ball': personCricketBattingAtCricketBall,
|
||||||
|
'person-boarding-tram-with-destination-display-and-pantograph-on-tram-track':
|
||||||
|
personBoardingTramWithDestinationDisplayAndPantographOnTramTrack,
|
||||||
|
'person-jockeying-racehorse': personJockeyingRacehorse,
|
||||||
|
'person-playing-tennis': personPlayingTennis,
|
||||||
|
'person-running': personRunning,
|
||||||
|
'person-sleeping-in-bed': personSleepingInBed,
|
||||||
|
'person-swimming-in-water': personSwimmingInWater,
|
||||||
|
'person-swinging-golf-club': personSwingingGolfClub,
|
||||||
phone,
|
phone,
|
||||||
|
'plane-top-right': planeTopRight,
|
||||||
|
'plant-in-raised-planter': plantInRaisedPlanter,
|
||||||
|
'place-of-worship-building': placeOfWorshipBuilding,
|
||||||
|
'play-structure-with-slide': playStructureWithSlide,
|
||||||
|
'police-officer-with-stop-arm': policeOfficerWithStopArm,
|
||||||
plus,
|
plus,
|
||||||
server,
|
'round-structure-with-flag': roundStructureWithFlag,
|
||||||
|
'sailing-ship-in-water': sailingShipInWater,
|
||||||
|
'scissors-open': scissorsOpen,
|
||||||
|
'shipwreck-in-water': shipwreckInWater,
|
||||||
|
'shopping-bag': shoppingBag,
|
||||||
search,
|
search,
|
||||||
|
server,
|
||||||
settings,
|
settings,
|
||||||
|
'shopping-basket': shoppingBasket,
|
||||||
|
'shopping-cart': shoppingCart,
|
||||||
|
'table-tennis-paddle': tableTennisPaddle,
|
||||||
|
'tattoo-machine': tattooMachine,
|
||||||
|
toolbox,
|
||||||
target,
|
target,
|
||||||
|
'tree-and-bench-with-backrest': treeAndBenchWithBackrest,
|
||||||
user,
|
user,
|
||||||
|
'village-buildings': villageBuildings,
|
||||||
|
'wall-hanging-with-mountains-and-sun': wallHangingWithMountainsAndSun,
|
||||||
|
'womens-and-mens-restroom-symbol': womensAndMensRestroomSymbol,
|
||||||
wikipedia,
|
wikipedia,
|
||||||
|
parking_p: parkingP,
|
||||||
x,
|
x,
|
||||||
zap,
|
zap,
|
||||||
|
'loading-ring': loadingRing,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const FILLED_ICONS = [
|
||||||
|
'fork-and-knife',
|
||||||
|
'wikipedia',
|
||||||
|
'cup-and-saucer',
|
||||||
|
'coffee-bean',
|
||||||
|
'shopping-basket',
|
||||||
|
'camera',
|
||||||
|
'person-sleeping-in-bed',
|
||||||
|
'loading-ring',
|
||||||
|
];
|
||||||
|
|
||||||
export function getIcon(name) {
|
export function getIcon(name) {
|
||||||
return ICONS[name];
|
return ICONS[name];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isIconFilled(name) {
|
||||||
|
return FILLED_ICONS.includes(name);
|
||||||
|
}
|
||||||
|
|||||||
222
app/utils/osm-icons.js
Normal file
222
app/utils/osm-icons.js
Normal file
@@ -0,0 +1,222 @@
|
|||||||
|
import { getIcon } from './icons';
|
||||||
|
|
||||||
|
// Rules for mapping OSM tags to icons.
|
||||||
|
// Rules are evaluated in order. The first rule where all specified tags match is used.
|
||||||
|
export const POI_ICON_RULES = [
|
||||||
|
// Specific Cuisine
|
||||||
|
{ tags: { cuisine: 'donut' }, icon: 'donut' },
|
||||||
|
{ tags: { cuisine: 'doughnut' }, icon: 'donut' },
|
||||||
|
{ tags: { cuisine: 'coffee_shop' }, icon: 'coffee-bean' },
|
||||||
|
{ tags: { cuisine: 'coffee' }, icon: 'coffee-bean' },
|
||||||
|
|
||||||
|
// General Amenity/Shop Types
|
||||||
|
{ tags: { amenity: 'ice_cream' }, icon: 'ice-cream-on-cone' },
|
||||||
|
{ tags: { cuisine: 'ice_cream' }, icon: 'ice-cream-on-cone' },
|
||||||
|
{ tags: { shop: 'ice_cream' }, icon: 'ice-cream-on-cone' },
|
||||||
|
|
||||||
|
{ tags: { amenity: 'cafe' }, icon: 'cup-and-saucer' },
|
||||||
|
{ tags: { amenity: 'restaurant' }, icon: 'fork-and-knife' },
|
||||||
|
{ tags: { amenity: 'fast_food' }, icon: 'burger-and-drink-cup-with-straw' },
|
||||||
|
{ tags: { amenity: 'pub' }, icon: 'beer-mug-with-foam' },
|
||||||
|
{ tags: { amenity: 'bar' }, icon: 'cocktail' },
|
||||||
|
{ tags: { amenity: 'food_court' }, icon: 'fork-and-knife' },
|
||||||
|
{ tags: { amenity: 'childcare' }, icon: 'family-restroom-symbol' },
|
||||||
|
{ tags: { amenity: 'community_centre' }, icon: 'family-restroom-symbol' },
|
||||||
|
{ tags: { amenity: 'social_centre' }, icon: 'family-restroom-symbol' },
|
||||||
|
{ tags: { amenity: 'social_facility' }, icon: 'family-restroom-symbol' },
|
||||||
|
|
||||||
|
{ tags: { amenity: 'bank' }, icon: 'banknote' },
|
||||||
|
{ tags: { amenity: 'place_of_worship' }, icon: 'place-of-worship-building' },
|
||||||
|
{ tags: { amenity: 'fire_station' }, icon: 'badge-shield-with-fire' },
|
||||||
|
{ tags: { amenity: 'police' }, icon: 'police-officer-with-stop-arm' },
|
||||||
|
{ tags: { amenity: 'toilets' }, icon: 'womens-and-mens-restroom-symbol' },
|
||||||
|
{ tags: { amenity: 'school' }, icon: 'open-book' },
|
||||||
|
|
||||||
|
{ tags: { shop: 'coffee' }, icon: 'coffee-bean' },
|
||||||
|
{ tags: { shop: 'tea' }, icon: 'coffee-bean' },
|
||||||
|
{ tags: { shop: 'pastry' }, icon: 'donut' },
|
||||||
|
|
||||||
|
// Shopping
|
||||||
|
{ tags: { shop: 'supermarket' }, icon: 'shopping-cart' },
|
||||||
|
{ tags: { shop: 'convenience' }, icon: 'shopping-basket' },
|
||||||
|
{ tags: { shop: 'grocery' }, icon: 'shopping-basket' },
|
||||||
|
{ tags: { shop: 'greengrocer' }, icon: 'shopping-basket' },
|
||||||
|
{ tags: { shop: 'bakery' }, icon: 'croissant' },
|
||||||
|
{ tags: { shop: 'butcher' }, icon: 'cleaver' },
|
||||||
|
{ tags: { shop: 'seafood' }, icon: 'fish' },
|
||||||
|
{ tags: { shop: 'deli' }, icon: 'shopping-basket' },
|
||||||
|
{ tags: { shop: 'clothes' }, icon: 'clothes-hanger' },
|
||||||
|
{ tags: { shop: 'clothing' }, icon: 'clothes-hanger' },
|
||||||
|
{ tags: { shop: 'hairdresser' }, icon: 'scissors-open' },
|
||||||
|
{ tags: { shop: 'optician' }, icon: 'eyeglasses' },
|
||||||
|
{ tags: { shop: 'fabric' }, icon: 'cloth' },
|
||||||
|
{ tags: { shop: 'flea_market' }, icon: 'market-stall' },
|
||||||
|
{ tags: { shop: 'kiosk' }, icon: 'shopping-basket' },
|
||||||
|
{ tags: { shop: 'leather' }, icon: 'shopping-bag' },
|
||||||
|
{ tags: { shop: 'tailor' }, icon: 'needle-and-spool-of-thread' },
|
||||||
|
{ tags: { shop: 'jewelry' }, icon: 'jewel' },
|
||||||
|
{ tags: { shop: 'jewellery' }, icon: 'jewel' },
|
||||||
|
{ tags: { shop: 'tobacco' }, icon: 'cigarette-with-smoke-curl' },
|
||||||
|
{ tags: { shop: 'cannabis' }, icon: 'cigarette-with-smoke-curl' },
|
||||||
|
{ tags: { shop: 'florist' }, icon: 'flower-bouquet' },
|
||||||
|
{ tags: { shop: 'garden_centre' }, icon: 'plant-in-raised-planter' },
|
||||||
|
{ tags: { shop: 'estate_agent' }, icon: 'village-buildings' },
|
||||||
|
{
|
||||||
|
tags: { shop: 'mobile_phone' },
|
||||||
|
icon: 'mobile-phone-with-keypad-and-antenna',
|
||||||
|
},
|
||||||
|
{ tags: { beauty: 'nails' }, icon: 'fingernail-polished' },
|
||||||
|
{ tags: { shop: 'tattoo' }, icon: 'tattoo-machine' },
|
||||||
|
{
|
||||||
|
tags: { shop: 'beauty' },
|
||||||
|
icon: 'fancy-mirror-with-reflection-and-stars',
|
||||||
|
},
|
||||||
|
{ tags: { craft: 'tailor' }, icon: 'needle-and-spool-of-thread' },
|
||||||
|
{ tags: { office: 'estate_agent' }, icon: 'village-buildings' },
|
||||||
|
{ tags: { office: true }, icon: 'commercial-building' },
|
||||||
|
{ tags: { craft: true }, icon: 'toolbox' },
|
||||||
|
{ tags: { shop: true }, icon: 'shopping-bag' },
|
||||||
|
|
||||||
|
// Natural
|
||||||
|
{ tags: { natural: 'beach' }, icon: 'beach-umbrella-in-ground' },
|
||||||
|
{ tags: { leisure: 'park' }, icon: 'tree-and-bench-with-backrest' },
|
||||||
|
{ tags: { leisure: 'playground' }, icon: 'play-structure-with-slide' },
|
||||||
|
|
||||||
|
// Transport
|
||||||
|
{ tags: { aeroway: 'aerodrome' }, icon: 'plane-top-right' },
|
||||||
|
{ tags: { aeroway: 'heliport' }, icon: 'plane-top-right' },
|
||||||
|
{ tags: { aeroway: 'helipad' }, icon: 'plane-top-right' },
|
||||||
|
{ tags: { highway: 'bus_stop' }, icon: 'bus' },
|
||||||
|
{ tags: { bus: true }, icon: 'bus' },
|
||||||
|
{
|
||||||
|
tags: { railway: 'tram_stop' },
|
||||||
|
icon: 'person-boarding-tram-with-destination-display-and-pantograph-on-tram-track',
|
||||||
|
},
|
||||||
|
|
||||||
|
// Tourism
|
||||||
|
{ tags: { tourism: 'museum' }, icon: 'classical-building' },
|
||||||
|
{ tags: { tourism: 'gallery' }, icon: 'wall-hanging-with-mountains-and-sun' },
|
||||||
|
{ tags: { tourism: 'aquarium' }, icon: 'angelfish' },
|
||||||
|
{ tags: { tourism: 'theme_park' }, icon: 'camera' },
|
||||||
|
{ tags: { tourism: 'attraction' }, icon: 'camera' },
|
||||||
|
{ tags: { tourism: 'viewpoint' }, icon: 'camera' },
|
||||||
|
{ tags: { tourism: 'zoo' }, icon: 'camera' },
|
||||||
|
{ tags: { tourism: 'artwork' }, icon: 'camera' },
|
||||||
|
{ tags: { amenity: 'cinema' }, icon: 'film' },
|
||||||
|
{ tags: { amenity: 'theatre' }, icon: 'camera' },
|
||||||
|
{ tags: { amenity: 'arts_centre' }, icon: 'comedy-mask-and-tragedy-mask' },
|
||||||
|
{ tags: { amenity: 'arts_center' }, icon: 'comedy-mask-and-tragedy-mask' },
|
||||||
|
|
||||||
|
// Historic
|
||||||
|
{ tags: { historic: 'fort' }, icon: 'fort' },
|
||||||
|
{ tags: { historic: 'castle' }, icon: 'palace' },
|
||||||
|
{ tags: { historic: 'building' }, icon: 'classical-building-with-flag' },
|
||||||
|
{ tags: { historic: 'archaeological_site' }, icon: 'grecian-vase' },
|
||||||
|
{ tags: { historic: 'memorial' }, icon: 'memorial-stone-with-inscription' },
|
||||||
|
{ tags: { historic: 'tomb' }, icon: 'gravestone' },
|
||||||
|
{
|
||||||
|
tags: { historic: 'monument' },
|
||||||
|
icon: 'classical-building-with-dome-and-flag',
|
||||||
|
},
|
||||||
|
{ tags: { historic: 'ship' }, icon: 'sailing-ship-in-water' },
|
||||||
|
{ tags: { historic: 'wreck' }, icon: 'shipwreck-in-water' },
|
||||||
|
{ tags: { historic: 'ruins' }, icon: 'camera' },
|
||||||
|
{ tags: { historic: 'ruin' }, icon: 'camera' },
|
||||||
|
{ tags: { historic: 'yes' }, icon: 'camera' },
|
||||||
|
|
||||||
|
// Accommodation
|
||||||
|
{ tags: { tourism: 'hotel' }, icon: 'person-sleeping-in-bed' },
|
||||||
|
{ tags: { tourism: 'hostel' }, icon: 'person-sleeping-in-bed' },
|
||||||
|
{ tags: { tourism: 'motel' }, icon: 'person-sleeping-in-bed' },
|
||||||
|
{ tags: { tourism: 'guest_house' }, icon: 'person-sleeping-in-bed' },
|
||||||
|
|
||||||
|
// Sports / Motorsports
|
||||||
|
{ tags: { sport: 'motor' }, icon: 'flag-checkered' },
|
||||||
|
{ tags: { sport: 'karting' }, icon: 'flag-checkered' },
|
||||||
|
{ tags: { sport: 'motocross' }, icon: 'flag-checkered' },
|
||||||
|
{
|
||||||
|
tags: { sport: 'cricket' },
|
||||||
|
icon: 'person-cricket-batting-at-cricket-ball',
|
||||||
|
},
|
||||||
|
{ tags: { sport: 'boxing' }, icon: 'boxing-glove-up' },
|
||||||
|
{ tags: { sport: 'martial_arts' }, icon: 'boxing-glove-up' },
|
||||||
|
{ tags: { sport: 'tennis' }, icon: 'person-playing-tennis' },
|
||||||
|
{ tags: { sport: 'squash' }, icon: 'person-playing-tennis' },
|
||||||
|
{ tags: { sport: 'padel' }, icon: 'person-playing-tennis' },
|
||||||
|
{ tags: { sport: 'table_tennis' }, icon: 'table-tennis-paddle' },
|
||||||
|
{ tags: { leisure: 'water_park' }, icon: 'person-swimming-in-water' },
|
||||||
|
{ tags: { sport: 'swimming' }, icon: 'person-swimming-in-water' },
|
||||||
|
{ tags: { sport: 'golf' }, icon: 'person-swinging-golf-club' },
|
||||||
|
{ tags: { leisure: 'golf_course' }, icon: 'person-swinging-golf-club' },
|
||||||
|
{ tags: { sport: 'horse_racing' }, icon: 'person-jockeying-racehorse' },
|
||||||
|
{ tags: { sport: 'fitness' }, icon: 'barbell' },
|
||||||
|
{ tags: { sport: 'fitness_centre' }, icon: 'barbell' },
|
||||||
|
{ tags: { leisure: 'fitness_centre' }, icon: 'barbell' },
|
||||||
|
|
||||||
|
{ tags: { sport: 'stadium' }, icon: 'round-structure-with-flag' },
|
||||||
|
{ tags: { leisure: 'stadium' }, icon: 'round-structure-with-flag' },
|
||||||
|
{ tags: { leisure: 'sports_centre' }, icon: 'person-running' },
|
||||||
|
{ tags: { leisure: 'pitch' }, icon: 'person-running' },
|
||||||
|
{ tags: { sport: true }, icon: 'person-running' },
|
||||||
|
|
||||||
|
// Healthcare
|
||||||
|
{ tags: { amenity: 'dentist' }, icon: 'molar-tooth' },
|
||||||
|
{ tags: { healthcare: 'dentist' }, icon: 'molar-tooth' },
|
||||||
|
{ tags: { healthcare: true }, icon: 'greek-cross' },
|
||||||
|
|
||||||
|
// Parking
|
||||||
|
{ tags: { amenity: 'parking' }, icon: 'parking_p' },
|
||||||
|
|
||||||
|
// Buildings
|
||||||
|
{ tags: { building: 'commercial' }, icon: 'commercial-building' },
|
||||||
|
{ tags: { building: 'apartments' }, icon: 'lowrise-building' },
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finds the appropriate icon name based on the place's OSM tags.
|
||||||
|
* @param {Object} tags - The OSM tags of the place.
|
||||||
|
* @returns {string|null} - The name of the icon or null if no match found.
|
||||||
|
*/
|
||||||
|
export function getIconNameForTags(tags) {
|
||||||
|
if (!tags) return null;
|
||||||
|
|
||||||
|
for (const rule of POI_ICON_RULES) {
|
||||||
|
let match = true;
|
||||||
|
for (const [key, expectedValue] of Object.entries(rule.tags)) {
|
||||||
|
const tagValue = tags[key];
|
||||||
|
if (!tagValue) {
|
||||||
|
match = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for exact match or if value is in a semicolon-separated list
|
||||||
|
// e.g. "donut;coffee_shop"
|
||||||
|
const values = tagValue.split(';').map((v) => v.trim());
|
||||||
|
|
||||||
|
// If expectedValue is boolean true, any value is a match
|
||||||
|
if (expectedValue === true) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!values.includes(expectedValue)) {
|
||||||
|
match = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (match) {
|
||||||
|
return rule.icon;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the raw SVG string for the icon corresponding to the given tags.
|
||||||
|
* @param {Object} tags - The OSM tags.
|
||||||
|
* @returns {string|null} - The raw SVG string or null.
|
||||||
|
*/
|
||||||
|
export function getIconSvgForTags(tags) {
|
||||||
|
const iconName = getIconNameForTags(tags);
|
||||||
|
if (!iconName) return null;
|
||||||
|
return getIcon(iconName);
|
||||||
|
}
|
||||||
65
app/utils/poi-categories.js
Normal file
65
app/utils/poi-categories.js
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
// This configuration defines the "Quick Search" categories available in the UI.
|
||||||
|
//
|
||||||
|
// Structure:
|
||||||
|
// - id: The URL slug used for routing (e.g. ?category=restaurants)
|
||||||
|
// - label: The human-readable name displayed in the UI
|
||||||
|
// - icon: The icon name (must be registered in app/utils/icons.js)
|
||||||
|
// - filter: An array of Overpass QL query parts.
|
||||||
|
// - Each string in the array is an independent query condition.
|
||||||
|
// - Multiple strings act as an OR condition (union of results).
|
||||||
|
|
||||||
|
export const POI_CATEGORIES = [
|
||||||
|
{
|
||||||
|
id: 'restaurants',
|
||||||
|
label: 'Restaurants',
|
||||||
|
icon: 'fork-and-knife',
|
||||||
|
filter: [
|
||||||
|
'["amenity"~"^(restaurant|fast_food|food_court|pub|cafe)$"]["cuisine"!~"coffee"]',
|
||||||
|
],
|
||||||
|
types: ['node', 'way'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'coffee',
|
||||||
|
label: 'Coffee',
|
||||||
|
icon: 'cup-and-saucer',
|
||||||
|
filter: [
|
||||||
|
'["amenity"~"^(cafe|ice_cream)$"]',
|
||||||
|
'["shop"~"^(coffee|tea)$"]',
|
||||||
|
'["cuisine"~"coffee_shop"]',
|
||||||
|
],
|
||||||
|
types: ['node', 'way'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'groceries',
|
||||||
|
label: 'Groceries',
|
||||||
|
icon: 'shopping-basket',
|
||||||
|
filter: [
|
||||||
|
'["shop"~"^(supermarket|convenience|grocery|greengrocer|bakery|butcher|deli|farm|seafood)$"]',
|
||||||
|
],
|
||||||
|
types: ['node', 'way'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'things-to-do',
|
||||||
|
label: 'Things to do',
|
||||||
|
icon: 'camera',
|
||||||
|
filter: [
|
||||||
|
'["tourism"~"^(museum|gallery|attraction|viewpoint|zoo|theme_park|aquarium|artwork)$"]',
|
||||||
|
'["amenity"~"^(cinema|theatre|arts_centre|planetarium)$"]',
|
||||||
|
'["leisure"~"^(sports_centre|stadium|water_park)$"]',
|
||||||
|
'["historic"]',
|
||||||
|
'["shop"="flea_market"]',
|
||||||
|
],
|
||||||
|
types: ['node', 'way', 'relation'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'accommodation',
|
||||||
|
label: 'Hotels',
|
||||||
|
icon: 'person-sleeping-in-bed',
|
||||||
|
filter: ['["tourism"~"^(hotel|hostel|motel)$"]'],
|
||||||
|
types: ['node', 'way', 'relation'],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function getCategoryById(id) {
|
||||||
|
return POI_CATEGORIES.find((c) => c.id === id);
|
||||||
|
}
|
||||||
File diff suppressed because one or more lines are too long
2
release/assets/main-C4F17h3W.js
Normal file
2
release/assets/main-C4F17h3W.js
Normal file
File diff suppressed because one or more lines are too long
1
release/assets/main-CKp1bFPU.css
Normal file
1
release/assets/main-CKp1bFPU.css
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -39,8 +39,8 @@
|
|||||||
<meta name="msapplication-TileColor" content="#F6E9A6">
|
<meta name="msapplication-TileColor" content="#F6E9A6">
|
||||||
<meta name="msapplication-TileImage" content="/icons/icon-144.png">
|
<meta name="msapplication-TileImage" content="/icons/icon-144.png">
|
||||||
|
|
||||||
<script type="module" crossorigin src="/assets/main-gEUnNw-L.js"></script>
|
<script type="module" crossorigin src="/assets/main-C4F17h3W.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/main-BOfcjRke.css">
|
<link rel="stylesheet" crossorigin href="/assets/main-CKp1bFPU.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
182
tests/acceptance/map-search-reset-test.js
Normal file
182
tests/acceptance/map-search-reset-test.js
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
import { module, test } from 'qunit';
|
||||||
|
import { visit, currentURL, waitFor, triggerEvent } from '@ember/test-helpers';
|
||||||
|
import { setupApplicationTest } from 'marco/tests/helpers';
|
||||||
|
import Service from '@ember/service';
|
||||||
|
import sinon from 'sinon';
|
||||||
|
|
||||||
|
module('Acceptance | map search reset', function (hooks) {
|
||||||
|
setupApplicationTest(hooks);
|
||||||
|
|
||||||
|
hooks.beforeEach(function () {
|
||||||
|
// Seed localStorage with a high zoom level to ensure map is interactive
|
||||||
|
const highZoomState = {
|
||||||
|
center: [13.4, 52.5],
|
||||||
|
zoom: 18,
|
||||||
|
};
|
||||||
|
window.localStorage.setItem(
|
||||||
|
'marco:map-view',
|
||||||
|
JSON.stringify(highZoomState)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Stub window.fetch using Sinon
|
||||||
|
// We want to intercept map style requests and let everything else through
|
||||||
|
this.fetchStub = sinon.stub(window, 'fetch');
|
||||||
|
|
||||||
|
this.fetchStub.callsFake(async (input, init) => {
|
||||||
|
let url = input;
|
||||||
|
if (typeof input === 'object' && input !== null && 'url' in input) {
|
||||||
|
url = input.url;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
typeof url === 'string' &&
|
||||||
|
url.includes('tiles.openfreemap.org/styles/liberty')
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
json: async () => ({
|
||||||
|
version: 8,
|
||||||
|
name: 'Liberty',
|
||||||
|
sources: {
|
||||||
|
openmaptiles: {
|
||||||
|
type: 'vector',
|
||||||
|
url: 'https://tiles.openfreemap.org/planet',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
layers: [
|
||||||
|
{
|
||||||
|
id: 'background',
|
||||||
|
type: 'background',
|
||||||
|
paint: {
|
||||||
|
'background-color': '#123456',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
glyphs:
|
||||||
|
'https://tiles.openfreemap.org/fonts/{fontstack}/{range}.pbf',
|
||||||
|
sprite: 'https://tiles.openfreemap.org/sprites/liberty',
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pass through to the original implementation
|
||||||
|
return this.fetchStub.wrappedMethod(input, init);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
hooks.afterEach(function () {
|
||||||
|
window.localStorage.removeItem('marco:map-view');
|
||||||
|
// Restore the original fetch
|
||||||
|
this.fetchStub.restore();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('clicking the map clears the category search parameter', async function (assert) {
|
||||||
|
// Mock OSM Service
|
||||||
|
class MockOsmService extends Service {
|
||||||
|
async getCategoryPois() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
title: 'Cafe Test',
|
||||||
|
lat: 52.52,
|
||||||
|
lon: 13.405,
|
||||||
|
osmId: '123',
|
||||||
|
osmType: 'N',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
async getNearbyPois() {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.owner.register('service:osm', MockOsmService);
|
||||||
|
|
||||||
|
// Mock Storage
|
||||||
|
this.owner.register(
|
||||||
|
'service:storage',
|
||||||
|
class extends Service {
|
||||||
|
rs = { on: () => {} };
|
||||||
|
placesInView = [];
|
||||||
|
savedPlaces = [];
|
||||||
|
loadPlacesInBounds() {
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
findPlaceById() {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// 1. Visit a category search URL
|
||||||
|
await visit('/search?category=coffee&lat=52.52&lon=13.405');
|
||||||
|
|
||||||
|
assert.dom('.sidebar-header').includesText('Results');
|
||||||
|
assert.ok(
|
||||||
|
currentURL().includes('category=coffee'),
|
||||||
|
'URL should have category param'
|
||||||
|
);
|
||||||
|
|
||||||
|
// 2. Click the map (First click closes sidebar)
|
||||||
|
await waitFor('canvas', { timeout: 2000 });
|
||||||
|
|
||||||
|
const canvas = document.querySelector('canvas');
|
||||||
|
if (canvas) {
|
||||||
|
// First Click (Close Sidebar)
|
||||||
|
await triggerEvent(canvas, 'pointerdown', {
|
||||||
|
clientX: 200,
|
||||||
|
clientY: 200,
|
||||||
|
button: 0,
|
||||||
|
isPrimary: true,
|
||||||
|
});
|
||||||
|
await triggerEvent(canvas, 'pointerup', {
|
||||||
|
clientX: 200,
|
||||||
|
clientY: 200,
|
||||||
|
button: 0,
|
||||||
|
isPrimary: true,
|
||||||
|
});
|
||||||
|
await triggerEvent(canvas, 'click', {
|
||||||
|
clientX: 200,
|
||||||
|
clientY: 200,
|
||||||
|
bubbles: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Wait for transition to index
|
||||||
|
await new Promise((r) => setTimeout(r, 500));
|
||||||
|
assert.strictEqual(
|
||||||
|
currentURL(),
|
||||||
|
'/',
|
||||||
|
'Should have transitioned to index (closed sidebar)'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Second Click (Start new search)
|
||||||
|
// Click slightly differently to ensure fresh event
|
||||||
|
await triggerEvent(canvas, 'pointerdown', {
|
||||||
|
clientX: 250,
|
||||||
|
clientY: 250,
|
||||||
|
button: 0,
|
||||||
|
isPrimary: true,
|
||||||
|
});
|
||||||
|
await triggerEvent(canvas, 'pointerup', {
|
||||||
|
clientX: 250,
|
||||||
|
clientY: 250,
|
||||||
|
button: 0,
|
||||||
|
isPrimary: true,
|
||||||
|
});
|
||||||
|
await triggerEvent(canvas, 'click', {
|
||||||
|
clientX: 250,
|
||||||
|
clientY: 250,
|
||||||
|
bubbles: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Wait for transition
|
||||||
|
await new Promise((r) => setTimeout(r, 1000));
|
||||||
|
|
||||||
|
const newUrl = currentURL();
|
||||||
|
assert.notOk(
|
||||||
|
newUrl.includes('category=coffee'),
|
||||||
|
`New URL ${newUrl} should not contain category param`
|
||||||
|
);
|
||||||
|
assert.ok(newUrl.includes('/search'), 'Should be on search route');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -64,25 +64,24 @@ module('Acceptance | navigation', function (hooks) {
|
|||||||
this.owner.register('service:storage', MockStorageService);
|
this.owner.register('service:storage', MockStorageService);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('navigating from search results to place and back uses history', async function (assert) {
|
test('navigating from search results to place and back returns to search', async function (assert) {
|
||||||
const mapUi = this.owner.lookup('service:map-ui');
|
const mapUi = this.owner.lookup('service:map-ui');
|
||||||
const backStub = sinon.stub(window.history, 'back');
|
|
||||||
|
|
||||||
try {
|
await visit('/search?lat=1&lon=1');
|
||||||
await visit('/search?lat=1&lon=1');
|
assert.strictEqual(currentURL(), '/search?lat=1&lon=1');
|
||||||
assert.strictEqual(currentURL(), '/search?lat=1&lon=1');
|
|
||||||
|
|
||||||
await click('.place-item');
|
await click('.place-item');
|
||||||
assert.ok(currentURL().includes('/place/'), 'Navigated to place');
|
assert.ok(currentURL().includes('/place/'), 'Navigated to place');
|
||||||
assert.true(mapUi.returnToSearch, 'Flag returnToSearch is set');
|
assert.true(mapUi.returnToSearch, 'Flag returnToSearch is set');
|
||||||
|
|
||||||
// Click the back button in the sidebar
|
// Click the back button in the sidebar
|
||||||
await click('.back-btn');
|
await click('.back-btn');
|
||||||
|
|
||||||
assert.true(backStub.calledOnce, 'window.history.back() was called');
|
assert.strictEqual(
|
||||||
} finally {
|
currentURL(),
|
||||||
backStub.restore();
|
'/search?lat=1&lon=1',
|
||||||
}
|
'Returned to search results'
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('closing the sidebar resets the returnToSearch flag', async function (assert) {
|
test('closing the sidebar resets the returnToSearch flag', async function (assert) {
|
||||||
|
|||||||
136
tests/acceptance/search-loading-test.js
Normal file
136
tests/acceptance/search-loading-test.js
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
import { module, test } from 'qunit';
|
||||||
|
import { visit, click, fillIn, currentURL } from '@ember/test-helpers';
|
||||||
|
import { setupApplicationTest } from 'marco/tests/helpers';
|
||||||
|
import Service from '@ember/service';
|
||||||
|
import { Promise } from 'rsvp';
|
||||||
|
|
||||||
|
class MockPhotonService extends Service {
|
||||||
|
cancelAll() {}
|
||||||
|
|
||||||
|
async search(query) {
|
||||||
|
// Simulate network delay
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
if (query === 'slow') {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
title: 'Test Place',
|
||||||
|
lat: 1,
|
||||||
|
lon: 1,
|
||||||
|
osmId: '123',
|
||||||
|
osmType: 'node',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class MockOsmService extends Service {
|
||||||
|
cancelAll() {}
|
||||||
|
|
||||||
|
async getCategoryPois(bounds, category) {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
if (category === 'slow_category') {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
async getNearbyPois() {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module('Acceptance | search loading', function (hooks) {
|
||||||
|
setupApplicationTest(hooks);
|
||||||
|
|
||||||
|
hooks.beforeEach(function () {
|
||||||
|
this.owner.register('service:photon', MockPhotonService);
|
||||||
|
this.owner.register('service:osm', MockOsmService);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('search shows loading indicator but nearby search does not', async function (assert) {
|
||||||
|
const mapUi = this.owner.lookup('service:map-ui');
|
||||||
|
|
||||||
|
// 1. Text Search
|
||||||
|
// Start a search and check for loading state immediately
|
||||||
|
const searchPromise = visit('/search?q=slow');
|
||||||
|
|
||||||
|
// We can't easily check the DOM mid-transition in acceptance tests without complicated helpers,
|
||||||
|
// so we check the service state which drives the UI.
|
||||||
|
// Wait a tiny bit for the route to start processing
|
||||||
|
await new Promise((r) => setTimeout(r, 10));
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
mapUi.loadingState,
|
||||||
|
{ type: 'text', value: 'slow' },
|
||||||
|
'Loading state is set for text search'
|
||||||
|
);
|
||||||
|
|
||||||
|
await searchPromise;
|
||||||
|
assert.strictEqual(
|
||||||
|
mapUi.loadingState,
|
||||||
|
null,
|
||||||
|
'Loading state is cleared after text search'
|
||||||
|
);
|
||||||
|
|
||||||
|
// 2. Category Search
|
||||||
|
const catPromise = visit('/search?category=slow_category&lat=1&lon=1');
|
||||||
|
await new Promise((r) => setTimeout(r, 10));
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
mapUi.loadingState,
|
||||||
|
{ type: 'category', value: 'slow_category' },
|
||||||
|
'Loading state is set for category search'
|
||||||
|
);
|
||||||
|
|
||||||
|
await catPromise;
|
||||||
|
assert.strictEqual(
|
||||||
|
mapUi.loadingState,
|
||||||
|
null,
|
||||||
|
'Loading state is cleared after category search'
|
||||||
|
);
|
||||||
|
|
||||||
|
// 3. Nearby Search
|
||||||
|
await visit('/search?lat=1&lon=1');
|
||||||
|
assert.strictEqual(
|
||||||
|
mapUi.loadingState,
|
||||||
|
null,
|
||||||
|
'Loading state is NOT set for nearby search'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('clearing search stops loading indicator', async function (assert) {
|
||||||
|
const mapUi = this.owner.lookup('service:map-ui');
|
||||||
|
|
||||||
|
// 1. Start from index
|
||||||
|
await visit('/');
|
||||||
|
|
||||||
|
// 2. Type "slow" to trigger autocomplete (which is async)
|
||||||
|
await fillIn('.search-input', 'slow');
|
||||||
|
|
||||||
|
// 3. Submit search to trigger route loading
|
||||||
|
click('.search-submit-btn'); // Intentionally no await to not block on transition
|
||||||
|
|
||||||
|
// Wait for loading state to activate
|
||||||
|
await new Promise((r) => setTimeout(r, 100));
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
mapUi.loadingState,
|
||||||
|
{ type: 'text', value: 'slow' },
|
||||||
|
'Loading state is set'
|
||||||
|
);
|
||||||
|
|
||||||
|
// 4. Click the clear button (should be visible since input has value)
|
||||||
|
await click('.search-clear-btn');
|
||||||
|
|
||||||
|
// Verify loading state is cleared immediately
|
||||||
|
assert.strictEqual(
|
||||||
|
mapUi.loadingState,
|
||||||
|
null,
|
||||||
|
'Loading state is cleared immediately after clicking clear'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Verify we are back on index (or at least query is gone)
|
||||||
|
assert.strictEqual(currentURL(), '/', 'Navigated to index');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -155,4 +155,67 @@ module('Acceptance | search', function (hooks) {
|
|||||||
assert.dom('.places-list li').exists({ count: 1 });
|
assert.dom('.places-list li').exists({ count: 1 });
|
||||||
assert.dom('.places-list li .place-name').hasText('My Secret Base');
|
assert.dom('.places-list li .place-name').hasText('My Secret Base');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('visiting /search with category parameter performs category search', async function (assert) {
|
||||||
|
// Mock Osm Service
|
||||||
|
class MockOsmService extends Service {
|
||||||
|
async getCategoryPois(bounds, categoryId) {
|
||||||
|
if (categoryId === 'coffee') {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
title: 'Latte Art Cafe',
|
||||||
|
lat: 52.52,
|
||||||
|
lon: 13.405,
|
||||||
|
osmId: '101',
|
||||||
|
osmType: 'N',
|
||||||
|
description: 'Best Coffee',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.owner.register('service:osm', MockOsmService);
|
||||||
|
|
||||||
|
// Mock Storage Service (empty)
|
||||||
|
class MockStorageService extends Service {
|
||||||
|
savedPlaces = [];
|
||||||
|
findPlaceById() {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
isPlaceSaved() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
rs = { on: () => {} };
|
||||||
|
placesInView = [];
|
||||||
|
loadPlacesInBounds() {
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.owner.register('service:storage', MockStorageService);
|
||||||
|
|
||||||
|
// Mock Map Service (needed for bounds)
|
||||||
|
class MockMapService extends Service {
|
||||||
|
getBounds() {
|
||||||
|
return {
|
||||||
|
minLat: 52.5,
|
||||||
|
minLon: 13.4,
|
||||||
|
maxLat: 52.6,
|
||||||
|
maxLon: 13.5,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.owner.register('service:map', MockMapService);
|
||||||
|
|
||||||
|
await visit('/search?category=coffee&lat=52.52&lon=13.405');
|
||||||
|
|
||||||
|
assert.strictEqual(
|
||||||
|
currentURL(),
|
||||||
|
'/search?category=coffee&lat=52.52&lon=13.405'
|
||||||
|
);
|
||||||
|
assert.dom('.places-list li').exists({ count: 1 });
|
||||||
|
assert.dom('.places-list li .place-name').hasText('Latte Art Cafe');
|
||||||
|
// Ensure it shows "Results" not "Nearby"
|
||||||
|
assert.dom('.sidebar-header h2').includesText('Results');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,13 +1,25 @@
|
|||||||
import { module, test } from 'qunit';
|
import { module, test } from 'qunit';
|
||||||
import { setupRenderingTest } from 'marco/tests/helpers';
|
import { setupRenderingTest } from 'marco/tests/helpers';
|
||||||
import { render } from '@ember/test-helpers';
|
import { render, fillIn } from '@ember/test-helpers';
|
||||||
import AppHeader from 'marco/components/app-header';
|
import AppHeader from 'marco/components/app-header';
|
||||||
|
import Service from '@ember/service';
|
||||||
|
|
||||||
module('Integration | Component | app-header', function (hooks) {
|
module('Integration | Component | app-header', function (hooks) {
|
||||||
setupRenderingTest(hooks);
|
setupRenderingTest(hooks);
|
||||||
|
|
||||||
test('it renders the search box', async function (assert) {
|
test('it renders the search box', async function (assert) {
|
||||||
this.noop = () => {};
|
this.noop = () => {};
|
||||||
|
|
||||||
|
class MockPhotonService extends Service {}
|
||||||
|
class MockRouterService extends Service {}
|
||||||
|
class MockMapUiService extends Service {}
|
||||||
|
class MockMapService extends Service {}
|
||||||
|
|
||||||
|
this.owner.register('service:photon', MockPhotonService);
|
||||||
|
this.owner.register('service:router', MockRouterService);
|
||||||
|
this.owner.register('service:map-ui', MockMapUiService);
|
||||||
|
this.owner.register('service:map', MockMapService);
|
||||||
|
|
||||||
await render(
|
await render(
|
||||||
<template><AppHeader @onToggleMenu={{this.noop}} /></template>
|
<template><AppHeader @onToggleMenu={{this.noop}} /></template>
|
||||||
);
|
);
|
||||||
@@ -16,4 +28,39 @@ module('Integration | Component | app-header', function (hooks) {
|
|||||||
assert.dom('.search-box').exists('Search box is present in the header');
|
assert.dom('.search-box').exists('Search box is present in the header');
|
||||||
assert.dom('.menu-btn-integrated').exists('Menu button is integrated');
|
assert.dom('.menu-btn-integrated').exists('Menu button is integrated');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('typing in search box toggles .searching class on header-center', async function (assert) {
|
||||||
|
this.noop = () => {};
|
||||||
|
|
||||||
|
class MockPhotonService extends Service {
|
||||||
|
search() {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
class MockRouterService extends Service {}
|
||||||
|
class MockMapUiService extends Service {
|
||||||
|
setSearchBoxFocus() {}
|
||||||
|
currentCenter = null;
|
||||||
|
}
|
||||||
|
class MockMapService extends Service {}
|
||||||
|
|
||||||
|
this.owner.register('service:photon', MockPhotonService);
|
||||||
|
this.owner.register('service:router', MockRouterService);
|
||||||
|
this.owner.register('service:map-ui', MockMapUiService);
|
||||||
|
this.owner.register('service:map', MockMapService);
|
||||||
|
|
||||||
|
await render(
|
||||||
|
<template><AppHeader @onToggleMenu={{this.noop}} /></template>
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.dom('.header-center').doesNotHaveClass('searching');
|
||||||
|
|
||||||
|
await fillIn('.search-input', 'test');
|
||||||
|
|
||||||
|
assert.dom('.header-center').hasClass('searching');
|
||||||
|
|
||||||
|
await fillIn('.search-input', '');
|
||||||
|
|
||||||
|
assert.dom('.header-center').doesNotHaveClass('searching');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
56
tests/integration/components/category-chips-test.gjs
Normal file
56
tests/integration/components/category-chips-test.gjs
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import { module, test } from 'qunit';
|
||||||
|
import { setupRenderingTest } from 'marco/tests/helpers';
|
||||||
|
import { render, click } from '@ember/test-helpers';
|
||||||
|
import CategoryChips from 'marco/components/category-chips';
|
||||||
|
import Service from '@ember/service';
|
||||||
|
import { POI_CATEGORIES } from 'marco/utils/poi-categories';
|
||||||
|
|
||||||
|
module('Integration | Component | category-chips', function (hooks) {
|
||||||
|
setupRenderingTest(hooks);
|
||||||
|
|
||||||
|
test('it renders the correct number of chips', async function (assert) {
|
||||||
|
class MockRouterService extends Service {}
|
||||||
|
class MockMapUiService extends Service {}
|
||||||
|
|
||||||
|
this.owner.register('service:router', MockRouterService);
|
||||||
|
this.owner.register('service:map-ui', MockMapUiService);
|
||||||
|
|
||||||
|
await render(<template><CategoryChips /></template>);
|
||||||
|
|
||||||
|
assert.dom('.category-chip').exists({ count: 5 });
|
||||||
|
|
||||||
|
// Check for some expected labels
|
||||||
|
assert.dom(this.element).includesText('Restaurants');
|
||||||
|
assert.dom(this.element).includesText('Coffee');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('clicking a chip triggers the @onSelect action', async function (assert) {
|
||||||
|
let selectedCategory;
|
||||||
|
this.handleSelect = (category) => {
|
||||||
|
selectedCategory = category;
|
||||||
|
};
|
||||||
|
|
||||||
|
class MockRouterService extends Service {
|
||||||
|
transitionTo() {}
|
||||||
|
}
|
||||||
|
class MockMapUiService extends Service {}
|
||||||
|
|
||||||
|
this.owner.register('service:router', MockRouterService);
|
||||||
|
this.owner.register('service:map-ui', MockMapUiService);
|
||||||
|
|
||||||
|
await render(
|
||||||
|
<template><CategoryChips @onSelect={{this.handleSelect}} /></template>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Find the chip for "Coffee"
|
||||||
|
const coffeeCategory = POI_CATEGORIES.find((c) => c.id === 'coffee');
|
||||||
|
const chip = Array.from(
|
||||||
|
this.element.querySelectorAll('.category-chip')
|
||||||
|
).find((el) => el.textContent.includes(coffeeCategory.label));
|
||||||
|
|
||||||
|
await click(chip);
|
||||||
|
|
||||||
|
assert.strictEqual(selectedCategory.id, 'coffee');
|
||||||
|
assert.strictEqual(selectedCategory.label, 'Coffee');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -100,7 +100,7 @@ module('Integration | Component | search-box', function (hooks) {
|
|||||||
.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));
|
.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));
|
||||||
|
|
||||||
assert.verifySteps([
|
assert.verifySteps([
|
||||||
'transitionTo: search {"queryParams":{"q":"berlin","selected":null,"lat":"52.5200","lon":"13.4050"}}',
|
'transitionTo: search {"queryParams":{"q":"berlin","selected":null,"category":null,"lat":"52.5200","lon":"13.4050"}}',
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -134,4 +134,96 @@ module('Integration | Component | search-box', function (hooks) {
|
|||||||
|
|
||||||
assert.verifySteps(['search: cafe, 52.52, 13.405']);
|
assert.verifySteps(['search: cafe, 52.52, 13.405']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('it allows typing even when controlled by parent with a query argument', async function (assert) {
|
||||||
|
class MockPhotonService extends Service {
|
||||||
|
async search() {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.owner.register('service:photon', MockPhotonService);
|
||||||
|
|
||||||
|
this.query = '';
|
||||||
|
this.updateQuery = (val) => {
|
||||||
|
this.set('query', val);
|
||||||
|
};
|
||||||
|
this.noop = () => {};
|
||||||
|
|
||||||
|
await render(
|
||||||
|
<template>
|
||||||
|
<SearchBox
|
||||||
|
@query={{this.query}}
|
||||||
|
@onQueryChange={{this.updateQuery}}
|
||||||
|
@onToggleMenu={{this.noop}}
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Initial state
|
||||||
|
assert.dom('.search-input').hasValue('');
|
||||||
|
|
||||||
|
// Simulate typing
|
||||||
|
await fillIn('.search-input', 't');
|
||||||
|
assert.dom('.search-input').hasValue('t', 'Input should show "t"');
|
||||||
|
|
||||||
|
await fillIn('.search-input', 'te');
|
||||||
|
assert.dom('.search-input').hasValue('te', 'Input should show "te"');
|
||||||
|
|
||||||
|
// Simulate external update (e.g. chip click)
|
||||||
|
this.set('query', 'restaurant');
|
||||||
|
// wait for re-render
|
||||||
|
await click('.search-input'); // just to trigger a change cycle or ensure stability
|
||||||
|
assert
|
||||||
|
.dom('.search-input')
|
||||||
|
.hasValue('restaurant', 'Input should update from external change');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('it triggers category search with current location when clicking category result', async function (assert) {
|
||||||
|
// Mock MapUi Service
|
||||||
|
class MockMapUiService extends Service {
|
||||||
|
currentCenter = { lat: 51.5074, lon: -0.1278 };
|
||||||
|
setSearchBoxFocus() {}
|
||||||
|
}
|
||||||
|
this.owner.register('service:map-ui', MockMapUiService);
|
||||||
|
|
||||||
|
// Mock Photon Service
|
||||||
|
class MockPhotonService extends Service {
|
||||||
|
async search() {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.owner.register('service:photon', MockPhotonService);
|
||||||
|
|
||||||
|
// Mock Router Service
|
||||||
|
class MockRouterService extends Service {
|
||||||
|
transitionTo(routeName, options) {
|
||||||
|
assert.step(`transitionTo: ${routeName} ${JSON.stringify(options)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.owner.register('service:router', MockRouterService);
|
||||||
|
|
||||||
|
this.noop = () => {};
|
||||||
|
await render(
|
||||||
|
<template><SearchBox @onToggleMenu={{this.noop}} /></template>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Type "Resta" to trigger "Restaurants" category match
|
||||||
|
await fillIn('.search-input', 'Resta');
|
||||||
|
|
||||||
|
// Wait for debounce (300ms) + execution
|
||||||
|
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
await delay(400);
|
||||||
|
|
||||||
|
// The first result should be the category match
|
||||||
|
assert.dom('.search-result-item').exists({ count: 1 });
|
||||||
|
assert.dom('.result-title').hasText('Restaurants');
|
||||||
|
|
||||||
|
// Click the result
|
||||||
|
await click('.search-result-item');
|
||||||
|
|
||||||
|
// Assert transition with lat/lon from map center
|
||||||
|
assert.verifySteps([
|
||||||
|
'transitionTo: search {"queryParams":{"q":"Restaurants","category":"restaurants","selected":null,"lat":"51.5074","lon":"-0.1278"}}',
|
||||||
|
]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
68
tests/unit/services/map-ui-test.js
Normal file
68
tests/unit/services/map-ui-test.js
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import { module, test } from 'qunit';
|
||||||
|
import { setupTest } from 'marco/tests/helpers';
|
||||||
|
|
||||||
|
module('Unit | Service | map-ui', function (hooks) {
|
||||||
|
setupTest(hooks);
|
||||||
|
|
||||||
|
test('it handles loading state correctly', function (assert) {
|
||||||
|
let service = this.owner.lookup('service:map-ui');
|
||||||
|
|
||||||
|
// Initial state
|
||||||
|
assert.strictEqual(
|
||||||
|
service.loadingState,
|
||||||
|
null,
|
||||||
|
'loadingState starts as null'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Start loading search A
|
||||||
|
service.startLoading('search', 'A');
|
||||||
|
assert.deepEqual(
|
||||||
|
service.loadingState,
|
||||||
|
{ type: 'search', value: 'A' },
|
||||||
|
'loadingState is set to search A'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Stop loading search A (successful case)
|
||||||
|
service.stopLoading('search', 'A');
|
||||||
|
assert.strictEqual(
|
||||||
|
service.loadingState,
|
||||||
|
null,
|
||||||
|
'loadingState is cleared when stopped with matching parameters'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('it handles race condition: stopLoading only clears if parameters match', function (assert) {
|
||||||
|
let service = this.owner.lookup('service:map-ui');
|
||||||
|
|
||||||
|
// 1. Start loading search A
|
||||||
|
service.startLoading('search', 'A');
|
||||||
|
assert.deepEqual(service.loadingState, { type: 'search', value: 'A' });
|
||||||
|
|
||||||
|
// 2. Start loading search B (interruption)
|
||||||
|
// In a real app, search B would start before search A finishes.
|
||||||
|
service.startLoading('search', 'B');
|
||||||
|
assert.deepEqual(
|
||||||
|
service.loadingState,
|
||||||
|
{ type: 'search', value: 'B' },
|
||||||
|
'loadingState updates to search B'
|
||||||
|
);
|
||||||
|
|
||||||
|
// 3. Search A finishes and tries to stop loading
|
||||||
|
// The service should ignore this because current loading state is for B
|
||||||
|
service.stopLoading('search', 'A');
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
service.loadingState,
|
||||||
|
{ type: 'search', value: 'B' },
|
||||||
|
'loadingState remains search B even after stopping search A'
|
||||||
|
);
|
||||||
|
|
||||||
|
// 4. Search B finishes
|
||||||
|
service.stopLoading('search', 'B');
|
||||||
|
assert.strictEqual(
|
||||||
|
service.loadingState,
|
||||||
|
null,
|
||||||
|
'loadingState is cleared when search B stops'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -251,4 +251,45 @@ module('Unit | Service | osm', function (hooks) {
|
|||||||
[30, 30],
|
[30, 30],
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('getCategoryPois uses cache when lat/lon matches', async function (assert) {
|
||||||
|
let service = this.owner.lookup('service:osm');
|
||||||
|
|
||||||
|
// Mock settings
|
||||||
|
service.settings = { overpassApi: 'http://test-api' };
|
||||||
|
|
||||||
|
// Mock fetchWithRetry
|
||||||
|
let fetchCount = 0;
|
||||||
|
service.fetchWithRetry = async () => {
|
||||||
|
fetchCount++;
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
elements: [{ id: 1, type: 'node', tags: { name: 'Test' } }],
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const bounds = { minLat: 0, minLon: 0, maxLat: 1, maxLon: 1 };
|
||||||
|
|
||||||
|
// First call - should fetch
|
||||||
|
await service.getCategoryPois(bounds, 'restaurants', 52.5, 13.4);
|
||||||
|
assert.strictEqual(fetchCount, 1, 'First call should trigger fetch');
|
||||||
|
|
||||||
|
// Second call with same lat/lon - should cache
|
||||||
|
await service.getCategoryPois(bounds, 'restaurants', 52.5, 13.4);
|
||||||
|
assert.strictEqual(
|
||||||
|
fetchCount,
|
||||||
|
1,
|
||||||
|
'Second call with same lat/lon should use cache'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Third call with diff lat/lon - should fetch
|
||||||
|
await service.getCategoryPois(bounds, 'restaurants', 52.6, 13.5);
|
||||||
|
assert.strictEqual(
|
||||||
|
fetchCount,
|
||||||
|
2,
|
||||||
|
'Call with different lat/lon should trigger fetch'
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
39
tests/unit/utils/osm-icons-test.js
Normal file
39
tests/unit/utils/osm-icons-test.js
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import { getIconNameForTags } from 'marco/utils/osm-icons';
|
||||||
|
import { module, test } from 'qunit';
|
||||||
|
|
||||||
|
module('Unit | Utility | osm-icons', function () {
|
||||||
|
test('it returns molar-tooth for amenity=dentist', function (assert) {
|
||||||
|
let result = getIconNameForTags({ amenity: 'dentist' });
|
||||||
|
assert.strictEqual(result, 'molar-tooth');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('it returns molar-tooth for healthcare=dentist', function (assert) {
|
||||||
|
let result = getIconNameForTags({ healthcare: 'dentist' });
|
||||||
|
assert.strictEqual(result, 'molar-tooth');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('it returns greek-cross for healthcare=hospital (catch-all)', function (assert) {
|
||||||
|
let result = getIconNameForTags({ healthcare: 'hospital' });
|
||||||
|
assert.strictEqual(result, 'greek-cross');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('it returns greek-cross for healthcare=yes (catch-all)', function (assert) {
|
||||||
|
let result = getIconNameForTags({ healthcare: 'yes' });
|
||||||
|
assert.strictEqual(result, 'greek-cross');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('it returns shopping-basket for known shop types like convenience', function (assert) {
|
||||||
|
let result = getIconNameForTags({ shop: 'convenience' });
|
||||||
|
assert.strictEqual(result, 'shopping-basket');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('it returns shopping-bag for unknown shop types (catch-all)', function (assert) {
|
||||||
|
let result = getIconNameForTags({ shop: 'unknown_shop_type' });
|
||||||
|
assert.strictEqual(result, 'shopping-bag');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('it returns null for unknown tags', function (assert) {
|
||||||
|
let result = getIconNameForTags({ foo: 'bar' });
|
||||||
|
assert.strictEqual(result, null);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user