Merge pull request 'Add category search, and search result markers with icons' (#35) from feature/poi_type_search into master
CI / Lint (push) Successful in 31s
CI / Test (push) Successful in 47s

Reviewed-on: #35
This commit was merged in pull request #35.
This commit is contained in:
raucao committed 2026-03-27 09:12:28 +00:00
commit d72e5f3de2
36 files changed
+1977 -131

No files matched your search

+27 -1
View File
@@ -6,10 +6,16 @@ import { on } from '@ember/modifier';
import Icon from '#components/icon';
import UserMenu from '#components/user-menu';
import SearchBox from '#components/search-box';
import CategoryChips from '#components/category-chips';
export default class AppHeaderComponent extends Component {
@service storage;
@tracked isUserMenuOpen = false;
@tracked searchQuery = '';
get hasQuery() {
return !!this.searchQuery;
}
@action
toggleUserMenu() {
@@ -21,10 +27,30 @@ export default class AppHeaderComponent extends Component {
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>
<header class="app-header">
<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 class="header-right">
+57
View 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>
}
+6 -2
View File
@@ -1,6 +1,6 @@
import Component from '@glimmer/component';
import { htmlSafe } from '@ember/template';
import { getIcon } from '../utils/icons';
import { getIcon, isIconFilled } from '../utils/icons';
export default class IconComponent extends Component {
get svg() {
@@ -25,10 +25,14 @@ export default class IconComponent extends Component {
return this.args.title || '';
}
get isFilled() {
return this.args.filled || isIconFilled(this.args.name);
}
<template>
{{#if this.svg}}
<span
class="icon {{if @filled 'icon-filled'}}"
class="icon {{if this.isFilled 'icon-filled'}}"
style={{this.style}}
title={{this.title}}
>
+170 -13
View File
@@ -2,7 +2,7 @@ import Component from '@glimmer/component';
import { service } from '@ember/service';
import { modifier } from 'ember-modifier';
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 defaultInteractions, DragPan } from 'ol/interaction.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 Point from 'ol/geom/Point.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 { getIcon } from '../utils/icons';
import { getIconNameForTags } from '../utils/osm-icons';
export default class MapComponent extends Component {
@service osm;
@@ -28,6 +30,7 @@ export default class MapComponent extends Component {
mapInstance;
bookmarkSource;
searchResultsSource;
selectedShapeSource;
searchOverlay;
searchOverlayElement;
@@ -110,6 +113,101 @@ export default class MapComponent extends Component {
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
let center = [14.21683569, 27.060114248];
let zoom = 2.661;
@@ -141,9 +239,14 @@ export default class MapComponent extends Component {
projection: 'EPSG:3857',
});
this.mapInstance = new Map({
this.mapInstance = new OlMap({
target: element,
layers: [openfreemap, selectedShapeLayer, bookmarkLayer],
layers: [
openfreemap,
selectedShapeLayer,
searchResultLayer,
bookmarkLayer,
],
view: view,
controls: defaultControls({
zoom: true,
@@ -178,7 +281,7 @@ export default class MapComponent extends Component {
const pinIcon = document.createElement('div');
pinIcon.className = 'selected-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');
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)
updateSelectedPin = modifier(() => {
const selected = this.mapUi.selectedPlace;
@@ -914,6 +1044,7 @@ export default class MapComponent extends Component {
const [maxLon, maxLat] = toLonLat([extent[2], extent[3]]);
const bbox = { minLat, minLon, maxLat, maxLon };
this.mapUi.updateBounds(bbox);
await this.storage.loadPlacesInBounds(bbox);
this.loadBookmarks(this.storage.placesInView);
@@ -945,6 +1076,7 @@ export default class MapComponent extends Component {
hitTolerance: 10,
});
let clickedBookmark = null;
let clickedSearchResult = null;
let selectedFeatureName = null;
if (features && features.length > 0) {
@@ -953,8 +1085,12 @@ export default class MapComponent extends Component {
console.debug(f);
}
const bookmarkFeature = features.find((f) => f.get('isBookmark'));
const searchResultFeature = features.find((f) => f.get('isSearchResult'));
if (bookmarkFeature) {
clickedBookmark = bookmarkFeature.get('originalPlace');
} else if (searchResultFeature) {
clickedSearchResult = searchResultFeature.get('originalPlace');
}
// Also get visual props for standard map click logic later
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
if (this.args.isSidebarOpen) {
// If it's a bookmark, we allow "switching" to it even if sidebar is open
if (clickedBookmark) {
// If it's a bookmark or search result, we allow "switching" to it even if sidebar is open
const targetPlace = clickedBookmark || clickedSearchResult;
if (targetPlace) {
console.debug(
'Clicked bookmark while sidebar open (switching):',
clickedBookmark
'Clicked feature while sidebar open (switching):',
targetPlace
);
this.mapUi.preventNextZoom = true;
this.router.transitionTo('place', clickedBookmark);
transitionToPlace(targetPlace);
return;
}
@@ -986,8 +1136,13 @@ export default class MapComponent extends Component {
// Normal behavior (sidebar is closed)
if (clickedBookmark) {
console.debug('Clicked bookmark:', clickedBookmark);
this.mapUi.preventNextZoom = true;
this.router.transitionTo('place', clickedBookmark);
transitionToPlace(clickedBookmark);
return;
}
if (clickedSearchResult) {
console.debug('Clicked search result:', clickedSearchResult);
transitionToPlace(clickedSearchResult);
return;
}
@@ -1028,6 +1183,7 @@ export default class MapComponent extends Component {
lat: lat.toFixed(6),
lon: lon.toFixed(6),
q: null, // Clear q to force spatial search
category: null, // Clear category to force spatial search
selected: selectedFeatureName || null,
};
@@ -1040,6 +1196,7 @@ export default class MapComponent extends Component {
{{this.setupMap}}
{{this.updateInteractions}}
{{this.updateBookmarks}}
{{this.updateSearchResults}}
{{this.updateSelectedPin}}
{{this.syncPulse}}
{{this.syncCreationMode}}
+2 -2
View File
@@ -318,7 +318,7 @@ export default class PlaceDetails extends Component {
{{#if this.cuisine}}
<p class="content-with-icon">
<Icon @name="fork-and-knife" @title="Cuisine" @filled={{true}} />
<Icon @name="fork-and-knife" @title="Cuisine" />
<span>
{{this.cuisine}}
</span>
@@ -393,7 +393,7 @@ export default class PlaceDetails extends Component {
{{#if this.wikipedia}}
<p class="content-with-icon">
<Icon @name="wikipedia" @title="Wikipedia" @filled={{true}} />
<Icon @name="wikipedia" @title="Wikipedia" />
<span>
<a
href="https://wikipedia.org/wiki/{{this.wikipedia}}"
+1 -1
View File
@@ -146,7 +146,7 @@ export default class PlacesSidebar extends Component {
get isNearbySearch() {
const qp = this.router.currentRoute.queryParams;
return !qp.q && qp.lat && qp.lon;
return !qp.q && !qp.category && qp.lat && qp.lon;
}
<template>
+94 -14
View File
@@ -7,38 +7,55 @@ import { fn } from '@ember/helper';
import { task, timeout } from 'ember-concurrency';
import Icon from '#components/icon';
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 {
@service photon;
@service osm;
@service router;
@service mapUi;
@service map; // Assuming we might need map context, but mostly we use router
@tracked query = '';
@tracked _internalQuery = '';
@tracked results = [];
@tracked isFocused = false;
@tracked isLoading = false;
get query() {
return this.args.query ?? this._internalQuery;
}
set query(value) {
this._internalQuery = value;
}
get showPopover() {
return this.isFocused && this.results.length > 0;
}
@action
handleInput(event) {
this.query = event.target.value;
if (this.query.length < 2) {
const value = event.target.value;
this.query = value;
if (this.args.onQueryChange) {
this.args.onQueryChange(value);
}
if (value.length < 2) {
this.results = [];
return;
}
this.searchTask.perform();
this.searchTask.perform(value);
}
searchTask = task({ restartable: true }, async () => {
searchTask = task({ restartable: true }, async (term) => {
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;
try {
@@ -47,8 +64,20 @@ export default class SearchBoxComponent extends Component {
if (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) {
console.error('Search failed', e);
this.results = [];
@@ -80,7 +109,7 @@ export default class SearchBoxComponent extends Component {
event.preventDefault();
if (!this.query) return;
let queryParams = { q: this.query, selected: null };
let queryParams = { q: this.query, selected: null, category: null };
if (this.mapUi.currentCenter) {
const { lat, lon } = this.mapUi.currentCenter;
@@ -94,7 +123,37 @@ export default class SearchBoxComponent extends Component {
@action
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;
if (this.args.onQueryChange) {
this.args.onQueryChange(place.title);
}
this.results = []; // Hide popover
// If it has an OSM ID, go to place details
@@ -112,6 +171,7 @@ export default class SearchBoxComponent extends Component {
lat: place.lat,
lon: place.lon,
selected: null,
category: null,
},
});
}
@@ -119,10 +179,17 @@ export default class SearchBoxComponent extends Component {
@action
clear() {
this.searchTask.cancelAll();
this.mapUi.stopLoading();
this.osm.cancelAll();
this.photon.cancelAll();
this.query = '';
this.results = [];
this.router.transitionTo('index'); // Or stay on current page?
// Usually clear just clears the input.
if (this.args.onQueryChange) {
this.args.onQueryChange('');
}
this.router.transitionTo('index');
}
<template>
@@ -150,7 +217,16 @@ export default class SearchBoxComponent extends Component {
/>
<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>
{{#if this.query}}
@@ -176,7 +252,11 @@ export default class SearchBoxComponent extends Component {
{{on "click" (fn this.selectResult result)}}
>
<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 class="result-info">
<span class="result-title">{{result.title}}</span>
+2 -1
View File
@@ -1,10 +1,11 @@
import Controller from '@ember/controller';
export default class SearchController extends Controller {
queryParams = ['lat', 'lon', 'q', 'selected'];
queryParams = ['lat', 'lon', 'q', 'selected', 'category'];
lat = null;
lon = null;
q = null;
selected = null;
category = null;
}
+1
View 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
View File
@@ -0,0 +1,10 @@
import Route from '@ember/routing/route';
import { service } from '@ember/service';
export default class IndexRoute extends Route {
@service mapUi;
activate() {
this.mapUi.clearSearchResults();
}
}
+110 -53
View File
@@ -15,74 +15,126 @@ export default class SearchRoute extends Route {
lon: { refreshModel: true },
q: { refreshModel: true },
selected: { refreshModel: true },
category: { refreshModel: true },
};
async model(params) {
const lat = params.lat ? parseFloat(params.lat) : null;
const lon = params.lon ? parseFloat(params.lon) : null;
let pois = [];
let loadingType = null;
let loadingValue = null;
// Case 1: Text Search (q parameter present)
if (params.q) {
// Search with Photon (using lat/lon for bias if available)
pois = await this.photon.search(params.q, lat, lon);
try {
// Case 0: Category Search (category parameter present)
if (params.category && lat && lon) {
loadingType = 'category';
loadingValue = params.category;
this.mapUi.startLoading(loadingType, loadingValue);
// 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)
);
});
// We need bounds. If we have active map state, use it.
let bounds = this.mapUi.currentBounds;
// 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);
// If we don't have bounds (direct URL visit), estimate them from lat/lon/zoom(16)
// or just use a fixed box around the center.
if (!bounds) {
// Approximate 0.01 degrees ~ 1km at equator. A viewport is roughly 0.02x0.01 at zoom 16?
// Let's take a safe box of ~1km radius.
const delta = 0.01;
bounds = {
minLat: lat - delta,
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.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)
pois = await this.osm.getCategoryPois(
bounds,
params.category,
lat,
lon
);
if (!exists) {
pois.push(local);
}
});
// Sort by distance from click
pois = pois
.map((p) => {
return {
// Sort by distance from center
pois = pois
.map((p) => ({
...p,
_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
@@ -139,6 +191,11 @@ export default class SearchRoute extends Route {
super.setupController(controller, model);
// Ensure pulse is stopped if we reach here
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
+39
View File
@@ -8,9 +8,13 @@ export default class MapUiService extends Service {
@tracked creationCoordinates = null;
@tracked returnToSearch = false;
@tracked currentCenter = null;
@tracked currentBounds = null;
@tracked searchBoxHasFocus = false;
@tracked selectionOptions = {};
@tracked preventNextZoom = false;
@tracked searchResults = [];
@tracked currentSearch = null;
@tracked loadingState = null;
selectPlace(place, options = {}) {
this.selectedPlace = place;
@@ -23,6 +27,15 @@ export default class MapUiService extends Service {
this.preventNextZoom = false;
}
setSearchResults(results) {
this.searchResults = results || [];
}
clearSearchResults() {
this.searchResults = [];
this.currentSearch = null;
}
startSearch() {
this.isSearching = true;
this.isCreating = false;
@@ -54,4 +67,30 @@ export default class MapUiService extends Service {
updateCenter(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;
}
}
}
+86 -3
View File
@@ -1,5 +1,6 @@
import Service, { service } from '@ember/service';
import { getLocalizedName, getPlaceType } from '../utils/osm';
import { getCategoryById } from '../utils/poi-categories';
export default class OsmService extends Service {
@service settings;
@@ -8,6 +9,13 @@ export default class OsmService extends Service {
cachedResults = null;
lastQueryKey = null;
cancelAll() {
if (this.controller) {
this.controller.abort();
this.controller = null;
}
}
async getNearbyPois(lat, lon, radius = 50) {
const queryKey = `${lat},${lon},${radius}`;
@@ -39,15 +47,26 @@ export default class OsmService extends Service {
];
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 = `
[out:json][timeout:25];
(
node(around:${radius},${lat},${lon})
[${typeKeysQuery}][~"^name"~"."];
[${typeKeysQuery}]${negativeFiltersQuery}[~"^name"~"."];
way(around:${radius},${lat},${lon})
[${typeKeysQuery}][~"^name"~"."];
[${typeKeysQuery}]${negativeFiltersQuery}[~"^name"~"."];
relation(around:${radius},${lat},${lon})
[${typeKeysQuery}][~"^name"~"."];
[${typeKeysQuery}]${negativeFiltersQuery}[~"^name"~"."];
);
out center;
`.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) {
const tags = poi.tags || {};
const type = getPlaceType(tags) || 'Point of Interest';
+19 -1
View File
@@ -5,6 +5,15 @@ import { humanizeOsmTag } from '../utils/format-text';
export default class PhotonService extends Service {
@service settings;
controller = null;
cancelAll() {
if (this.controller) {
this.controller.abort();
this.controller = null;
}
}
get baseUrl() {
return this.settings.photonApi;
}
@@ -12,6 +21,12 @@ export default class PhotonService extends Service {
async search(query, lat, lon, limit = 10) {
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({
q: query,
limit: String(limit),
@@ -25,7 +40,7 @@ export default class PhotonService extends Service {
const url = `${this.baseUrl}?${params.toString()}`;
try {
const res = await this.fetchWithRetry(url);
const res = await this.fetchWithRetry(url, { signal });
if (!res.ok) {
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));
} catch (e) {
if (e.name === 'AbortError') {
return [];
}
console.error('Photon search error:', e);
// Return empty array on error so UI doesn't break
return [];
+140 -9
View File
@@ -6,6 +6,8 @@
--sidebar-width: 350px;
--link-color: #2a7fff;
--link-color-visited: #6a4fbf;
--marker-color-primary: #ea4335;
--marker-color-dark: #b31412;
}
html,
@@ -70,27 +72,96 @@ body {
right: 0;
height: 60px;
padding: 0.5rem 1rem;
display: flex;
justify-content: space-between;
align-items: center;
z-index: 3000; /* Above sidebar (2000) and map */
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) {
.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-right {
pointer-events: auto; /* Re-enable clicks for buttons */
.header-right,
.header-center {
pointer-events: auto; /* Re-enable clicks */
}
.header-left {
display: flex;
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 {
@@ -803,15 +874,15 @@ span.icon {
.selected-pin {
width: 40px;
height: 40px;
color: #ea4335; /* Google Red */
color: var(--marker-color-primary);
filter: drop-shadow(0 4px 6px rgb(0 0 0 / 30%));
}
.selected-pin svg {
width: 100%;
height: 100%;
fill: #ea4335;
stroke: #b31412; /* Darker red stroke */
fill: var(--marker-color-primary);
stroke: var(--marker-color-dark);
stroke-width: 1;
}
@@ -1190,3 +1261,63 @@ button.create-place {
background: #eee;
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;
}
+5 -3
View File
@@ -77,9 +77,11 @@ export default class PlaceTemplate extends Component {
navigateBack(place) {
// The sidebar calls this with null when "Back" is clicked.
if (place === null) {
// If we came from search results, go back in history
if (this.mapUi.returnToSearch) {
window.history.back();
// If we have an active search context, return to it (UP navigation)
if (this.mapUi.returnToSearch && this.mapUi.currentSearch) {
this.router.transitionTo('search', {
queryParams: this.mapUi.currentSearch,
});
} else {
// Otherwise just close the sidebar (return to map index)
this.router.transitionTo('index');
+2
View File
@@ -11,6 +11,8 @@ export default class SearchTemplate extends Component {
selectPlace(place) {
if (place) {
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);
}
}
+174 -6
View File
@@ -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 arrowLeft from 'feather-icons/dist/icons/arrow-left.svg?raw';
import bookmark from 'feather-icons/dist/icons/bookmark.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 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 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 globe from 'feather-icons/dist/icons/globe.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 logIn from 'feather-icons/dist/icons/log-in.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 map from 'feather-icons/dist/icons/map.svg?raw';
import mapPin from 'feather-icons/dist/icons/map-pin.svg?raw';
import menu from 'feather-icons/dist/icons/menu.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 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 server from 'feather-icons/dist/icons/server.svg?raw';
import settings from 'feather-icons/dist/icons/settings.svg?raw';
import target from 'feather-icons/dist/icons/target.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 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 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 = {
'arrow-left': arrowLeft,
activity,
angelfish,
'arrow-left': arrowLeft,
barbell,
banknote,
'badge-shield-with-fire': badgeShieldWithFire,
'beach-umbrella-in-ground': beachUmbrellaInGround,
'beer-mug-with-foam': beerMugWithFoam,
bookmark,
'boxing-glove-up': boxingGloveUp,
'burger-and-drink-cup-with-straw': burgerAndDrinkCupWithStraw,
bus,
camera,
'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,
'coffee-bean': coffeeBean,
'comedy-mask-and-tragedy-mask': comedyMaskAndTragedyMask,
croissant,
'cup-and-saucer': cupAndSaucer,
donut,
edit,
eyeglasses,
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,
globe,
gravestone,
'grecian-vase': grecianVase,
'greek-cross': greekCross,
heart,
home,
'ice-cream-on-cone': iceCreamOnCone,
info,
instagram,
'fork-and-knife': forkAndKnife,
jewel,
'log-in': logIn,
'log-out': logOut,
'lowrise-building': lowriseBuilding,
mail,
map,
'map-pin': mapPin,
'market-stall': marketStall,
'memorial-stone-with-inscription': memorialStoneWithInscription,
menu,
'mobile-phone-with-keypad-and-antenna': mobilePhoneWithKeypadAndAntenna,
'molar-tooth': molarTooth,
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,
'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,
server,
'round-structure-with-flag': roundStructureWithFlag,
'sailing-ship-in-water': sailingShipInWater,
'scissors-open': scissorsOpen,
'shipwreck-in-water': shipwreckInWater,
'shopping-bag': shoppingBag,
search,
server,
settings,
'shopping-basket': shoppingBasket,
'shopping-cart': shoppingCart,
'table-tennis-paddle': tableTennisPaddle,
'tattoo-machine': tattooMachine,
toolbox,
target,
'tree-and-bench-with-backrest': treeAndBenchWithBackrest,
user,
'village-buildings': villageBuildings,
'wall-hanging-with-mountains-and-sun': wallHangingWithMountainsAndSun,
'womens-and-mens-restroom-symbol': womensAndMensRestroomSymbol,
wikipedia,
parking_p: parkingP,
x,
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) {
return ICONS[name];
}
export function isIconFilled(name) {
return FILLED_ICONS.includes(name);
}
+222
View 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
View 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);
}
-1
View File
@@ -1 +0,0 @@
:root{--default-list-color: #fc3;--hover-bg: #f8f9fa;--sidebar-width: 350px;--link-color: #2a7fff;--link-color-visited: #6a4fbf}html,body{height:100%;overscroll-behavior:none;-webkit-text-size-adjust:100%;-webkit-tap-highlight-color:transparent}button{-webkit-tap-highlight-color:transparent}body{margin:0;font-family:Noto Sans,sans-serif;font-size:16px;color:#333}#root,#ember-testing{height:100%}.map-container{background:#f8f9fa;-webkit-tap-highlight-color:transparent;outline:none;position:absolute;inset:0}#rs-widget-container{position:absolute;top:60px;right:10px;z-index:4000;display:none}#rs-widget-container.visible{display:block}.rs-backdrop{position:fixed;inset:0;z-index:3999}.app-header{position:absolute;top:0;left:0;right:0;height:60px;padding:.5rem 1rem;display:flex;justify-content:space-between;align-items:center;z-index:3000;pointer-events:none}@media(max-width:768px){.app-header{padding:0 .5rem}}.header-left,.header-right{pointer-events:auto}.header-left{display:flex;align-items:center}.btn-press{transition:transform .1s}.btn-press:active{transform:scale(.95)}.user-btn{background:none;border:none;cursor:pointer;padding:0}.user-avatar-placeholder{width:40px;height:40px;background:#2a3743;border-radius:50%;display:flex;align-items:center;justify-content:center;box-shadow:0 2px 5px #0003}.user-menu-container{position:relative}.user-menu-popover{position:absolute;top:100%;right:0;margin-top:10px;width:280px;background:#fff;border-radius:8px;box-shadow:0 4px 12px #00000026;padding:1rem;z-index:3001}.menu-backdrop{position:fixed;inset:0;z-index:3000}.user-status{padding-bottom:1rem;margin-bottom:1rem;border-bottom:1px solid #eee;color:#666;font-size:.9rem}.account-list{list-style:none;padding:0;margin:0}.account-item{display:flex;justify-content:space-between;align-items:center;padding:.75rem 0}.account-item.disabled{opacity:.5;pointer-events:none}.account-info{display:flex;align-items:center;gap:.75rem;font-weight:500}.btn-text{background:none;border:none;padding:0;font-weight:600;cursor:pointer;font-size:.9rem}.text-primary{color:var(--link-color)}.text-danger{color:#dc3545}.btn-text:hover{text-decoration:underline}.sidebar{position:absolute;top:0;left:0;bottom:0;width:var(--sidebar-width);background:#fff;z-index:3100;box-shadow:2px 0 5px #0000001a;display:flex;flex-direction:column;overflow:hidden}.sidebar.app-menu-pane{z-index:3200}@media(max-width:768px){.sidebar.app-menu-pane{width:100%;right:0;border-radius:16px 16px 0 0;height:66vh;top:auto;bottom:0}}.sidebar-header{padding:1rem;border-bottom:1px solid #eee;display:flex;justify-content:space-between;align-items:center}.sidebar-header h2{margin:0;font-size:1.2rem;display:flex;align-items:center;gap:.5rem}.sidebar-content{padding:1rem;overflow-y:auto;-webkit-overflow-scrolling:touch;flex:1;min-height:0;touch-action:pan-y;overscroll-behavior:contain}.app-menu-pane .sidebar-content{padding-top:.5rem}.app-menu{list-style:none;padding:0;margin:0 -1rem}.app-menu button{width:100%;display:flex;align-items:center;gap:.8rem;padding:1rem 1rem 1rem 1.4rem;background:none;border:none;color:#333;cursor:pointer;text-align:left;font-size:.95rem;font-family:inherit;transition:background-color .2s}.app-menu button:hover{background-color:var(--hover-bg)}.app-menu .icon{color:#666;width:20px;height:20px}.sidebar-content details{margin:0 -1rem}.sidebar-content details summary{list-style:none;display:flex;align-items:center;gap:.8rem;padding:1rem 1rem 1rem 1.4rem;cursor:pointer;font-size:.95rem;color:#333;transition:background-color .2s}.sidebar-content details summary::-webkit-details-marker{display:none}.sidebar-content details summary:hover{background-color:var(--hover-bg)}.sidebar-content details summary .icon{width:20px;height:20px}.sidebar-content details summary:after{content:"";width:20px;height:20px;background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='%23666' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='9 18 15 12 9 6'/%3E%3C/svg%3E");background-size:20px 20px;background-repeat:no-repeat;background-position:center;margin-left:auto;transition:transform .2s ease}.sidebar-content details[open] summary:after{transform:rotate(90deg)}.sidebar-content details .details-content{padding:0 1.4rem 1rem;animation:details-slide-down .2s ease-out;font-size:.9rem}@keyframes details-slide-down{0%{opacity:0;transform:translateY(-5px)}to{opacity:1;transform:translateY(0)}}.edit-form{margin:-1rem -1rem 1rem;background:var(--hover-bg);padding:1rem;border-bottom:1px solid #eee}.form-group{margin-bottom:.75rem}.form-group label{display:block;font-size:.85rem;color:#666;margin-bottom:.25rem}.form-control{width:100%;padding:.5rem;border:1px solid #ddd;border-radius:4px;font-family:inherit;font-size:1rem;box-sizing:border-box;color:#333;background-color:#fff}.form-control:focus{outline:none;border-color:var(--link-color);box-shadow:0 0 0 Line truncated
+2
View File
@@ -0,0 +1,2 @@
const t=Object.freeze(Object.defineProperty({__proto__:null,get default(){return YV}},Symbol.toStringTag,{value:"Module"}));!function(){const t=document.createElement("link").relList;if(!(t&&t.supports&&t.supports("modulepreload"))){for(const t of document.querySelectorAll('link[rel="modulepreload"]'))e(t);new MutationObserver(t=>{for(const n of t)if("childList"===n.type)for(const t of n.addedNodes)"LINK"===t.tagName&&"modulepreload"===t.rel&&e(t)}).observe(document,{childList:!0,subtree:!0})}function e(t){if(t.ep)return;t.ep=!0;const e=function(t){const e={};return t.integrity&&(e.integrity=t.integrity),t.referrerPolicy&&(e.referrerPolicy=t.referrerPolicy),"use-credentials"===t.crossOrigin?e.credentials="include":"anonymous"===t.crossOrigin?e.credentials="omit":e.credentials="same-origin",e}(t);fetch(t.href,e)}}();const e="object"==typeof self&&null!==self&&self.Object===Object&&"undefined"!=typeof Window&&self.constructor===Window&&"object"==typeof document&&null!==document&&self.document===document&&"object"==typeof location&&null!==location&&self.location===location&&"object"==typeof history&&null!==history&&self.history===history&&"object"==typeof navigator&&null!==navigator&&self.navigator===navigator&&"string"==typeof navigator.userAgent,n=e?self:null,i=e?self.location:null,r=e?self.history:null,s=e?self.navigator.userAgent:"Lynx (textmode)",o=!(!e||"object"!=typeof chrome||"object"==typeof opera),a=!!e&&/Firefox|FxiOS/.test(s),l=Object.freeze(Object.defineProperty({__proto__:null,hasDOM:e,history:r,isChrome:o,isFirefox:a,location:i,userAgent:s,window:n},Symbol.toStringTag,{value:"Module"}));function c(t){return t&&t.Object===Object?t:void 0}const h=c((u="object"==typeof global&&global)&&void 0===u.nodeType?u:void 0)||c("object"==typeof self&&self)||c("object"==typeof window&&window)||"undefined"!=typeof mainContext&&mainContext||new Function("return this")();var u;const d=function(t,e){return void 0===e?{imports:t,exports:t,lookup:t}:{imports:e.imports||t,exports:e.exports||t,lookup:e.lookup||t}}(h,h.Ember);function p(){return d.lookup}function f(t){d.lookup=t}const g={ENABLE_OPTIONAL_FEATURES:!1,EXTEND_PROTOTYPES:{Array:!1},LOG_STACKTRACE_ON_DEPRECATION:!0,LOG_VERSION:!0,RAISE_ON_DEPRECATION:!1,STRUCTURED_PROFILE:!1,_DEBUG_RENDER_TREE:!1,_ALL_DEPRECATIONS_ENABLED:!1,_OVERRIDE_DEPRECATION_VERSION:null,_DEFAULT_ASYNC_OBSERVERS:!1,_RERENDER_LOOP_LIMIT:1e3,EMBER_LOAD_HOOKS:{},FEATURES:{}};function m(){return g}(t=>{if("object"!=typeof t||null===t)return;for(let i in t){if(!Object.prototype.hasOwnProperty.call(t,i)||"EXTEND_PROTOTYPES"===i||"EMBER_LOAD_HOOKS"===i)continue;let e=g[i];g[i]=!0===e?!1!==t[i]:!1===e?!0===t[i]:t[i]}let{EMBER_LOAD_HOOKS:e}=t;if("object"==typeof e&&null!==e)for(let i in e){if(!Object.prototype.hasOwnProperty.call(e,i))continue;let t=e[i];Array.isArray(t)&&(g.EMBER_LOAD_HOOKS[i]=t.filter(t=>"function"==typeof t))}let{FEATURES:n}=t;if("object"==typeof n&&null!==n)for(let i in n)Object.prototype.hasOwnProperty.call(n,i)&&(g.FEATURES[i]=!0===n[i])})(h.EmberENV);let y=()=>{},_=()=>{},v=!1;function b(){return v}function w(t){v=Boolean(t)}let x=()=>{};const{toString:S}=Object.prototype,{toString:C}=Function.prototype,{isArray:k}=Array,{keys:E}=Object,{stringify:T}=JSON,R=100,P=/^[\w$]+$/;function I(t){return"number"==typeof t&&2===arguments.length?this:M(t,0)}function M(t,e,n){let i=!1;switch(typeof t){case"undefined":return"undefined";case"object":if(null===t)return"null";if(k(t)){i=!0;break}if(t.toString===S||void 0===t.toString)break;return t.toString();case"function":return t.toString===C?t.name?`[Function:${t.name}]`:"[Function]":t.toString();case"string":return T(t);default:return t.toString()}if(void 0===n)n=new WeakSet;else if(n.has(t))return"[Circular]";return n.add(t),i?function(t,e,n){if(e>4)return"[Array]";let i="[";for(let r=0;r<t.length;r++){if(i+=0===r?" ":", ",r>=R){i+=`... ${t.length-R} more items`;break}i+=M(t[r],e,n)}return i+=" ]",i}(t,e+1,n):function(t,e,n){if(e>4)return"[Object]";let i="{",r=E(t);for(let s=0;s<r.length;s++){if(i+=0===s?" ":", ",s>=R){i+=`... ${r.length-R} more keys`;break}let o=r[s];i+=`${O(String(o))}: ${M(t[o],e,n)}`}return i+=" }",i}(t,e+1,n)}function O(t){return P.test(t)?t:T(t)}function A(t){let e=t.lookup("renderer:-dom");if(!e)throw new Error("BUG: owner is missing renderer");return e.debugRenderTree.capture()}const L=()=>{};let F=L,z=L,j=L,D=L,N=L,U=L,B=L,q=L,$=function(){return arguments[arguments.length-1]};function V(){}const G=Object.freeze(Object.defineProperty({__proto__:null,_warnIfUsingStrippedFeatureFlags:void 0,assert:y,captureRenderTree:A,debug:j,debugFreeze:N,debugSeal:D,deprecate:V,deprecateFunc:$,getDebugFunction:q,info:F,inspect:I,isTesting:b,registerDeprecationHandler:_,registerWarnHandler:x,runInDebug:U,setDebugFunction:B,setTesting:w,warn:z},Symbol.toStringTag,{value:"Module"}));function W(t){let e=Object.create(null);e[t]=1;for(let n in e)if(n===t)return n;return t}function H(t){return null!==t&&("object"==typeof t||"fLine truncated
/*! For license information please see remotestorage.js.LICENSE.txt */const Qx=Yx(Jx),tS="0123456789bcdefghjkmnpqrstuvwxyz";class eS{static encode(t,e,n){if(void 0===n){for(let n=1;n<=12;n++){const i=eS.encode(t,e,n),r=eS.decode(i);if(r.lat==t&&r.lon==e)return i}n=12}if(t=Number(t),e=Number(e),n=Number(n),isNaN(t)||isNaN(e)||isNaN(n))throw new Error("Invalid geohash");let i=0,r=0,s=!0,o="",a=-90,l=90,c=-180,h=180;for(;o.length<n;){if(s){const t=(c+h)/2;e>=t?(i=2*i+1,c=t):(i*=2,h=t)}else{const e=(a+l)/2;t>=e?(i=2*i+1,a=e):(i*=2,l=e)}s=!s,5==++r&&(o+=tS.charAt(i),r=0,i=0)}return o}static decode(t){const e=eS.bounds(t),n=e.sw.lat,i=e.sw.lon,r=e.ne.lat,s=e.ne.lon;let o=(n+r)/2,a=(i+s)/2;return o=o.toFixed(Math.floor(2-Math.log(r-n)/Math.LN10)),a=a.toFixed(Math.floor(2-Math.log(s-i)/Math.LN10)),{lat:Number(o),lon:Number(a)}}static bounds(t){if(0==t.length)throw new Error("Invalid geohash");t=t.toLowerCase();let e=!0,n=-90,i=90,r=-180,s=180;for(let o=0;o<t.length;o++){const a=t.charAt(o),l=tS.indexOf(a);if(-1==l)throw new Error("Invalid geohash");for(let t=4;t>=0;t--){const o=l>>t&1;if(e){const t=(r+s)/2;1==o?r=t:s=t}else{const t=(n+i)/2;1==o?n=t:i=t}e=!e}}return{sw:{lat:n,lon:r},ne:{lat:i,lon:s}}}static adjacent(t,e){if(t=t.toLowerCase(),e=e.toLowerCase(),0==t.length)throw new Error("Invalid geohash");if(-1=="nsew".indexOf(e))throw new Error("Invalid direction");const n=t.slice(-1);let i=t.slice(0,-1);const r=t.length%2;return-1!={n:["prxz","bcfguvyz"],s:["028b","0145hjnp"],e:["bcfguvyz","prxz"],w:["0145hjnp","028b"]}[e][r].indexOf(n)&&""!=i&&(i=eS.adjacent(i,e)),i+tS.charAt({n:["p0r21436x8zb9dcf5h7kjnmqesgutwvy","bc01fg45238967deuvhjyznpkmstqrwx"],s:["14365h7k9dcfesgujnmqp0r2twvyx8zb","238967debc01fg45kmstqrwxuvhjyznp"],e:["bc01fg45238967deuvhjyznpkmstqrwx","p0r21436x8zb9dcf5h7kjnmqesgutwvy"],w:["238967debc01fg45kmstqrwxuvhjyznp","14365h7k9dcfesgujnmqp0r2twvyx8zb"]}[e][r].indexOf(n))}static neighbours(t){return{n:eS.adjacent(t,"n"),ne:eS.adjacent(eS.adjacent(t,"n"),"e"),e:eS.adjacent(t,"e"),se:eS.adjacent(eS.adjacent(t,"s"),"e"),s:eS.adjacent(t,"s"),sw:eS.adjacent(eS.adjacent(t,"s"),"w"),w:eS.adjacent(t,"w"),nw:eS.adjacent(eS.adjacent(t,"n"),"w")}}}const nS="0123456789ABCDEFGHJKMNPQRSTVWXYZ",iS=0xffffffffffff;var rS;!function(t){t.Base32IncorrectEncoding="B32_ENC_INVALID",t.DecodeTimeInvalidCharacter="DEC_TIME_CHAR",t.DecodeTimeValueMalformed="DEC_TIME_MALFORMED",t.EncodeTimeNegative="ENC_TIME_NEG",t.EncodeTimeSizeExceeded="ENC_TIME_SIZE_EXCEED",t.EncodeTimeValueMalformed="ENC_TIME_MALFORMED",t.PRNGDetectFailure="PRNG_DETECT",t.ULIDInvalid="ULID_INVALID",t.Unexpected="UNEXPECTED",t.UUIDInvalid="UUID_INVALID"}(rS||(rS={}));class sS extends Error{constructor(t,e){super(`${e} (${t})`),this.name="ULIDError",this.code=t}}function oS(t){const e=Math.floor(32*t())%32;return nS.charAt(e)}const aS={type:"object",properties:{id:{type:"string"},title:{type:"string"},lat:{type:"number"},lon:{type:"number"},geohash:{type:"string"},zoom:{type:"number"},url:{type:"string"},osmId:{type:"string"},osmType:{type:"string"},osmTags:{type:"object",additionalProperties:{type:"string"}},description:{type:"string"},tags:{type:"array",items:{type:"string"},default:[]},createdAt:{type:"string",format:"date-time"},updatedAt:{type:"string",format:"date-time"}},required:["id","title","lat","lon","geohash","createdAt"]},lS={type:"object",properties:{id:{type:"string"},title:{type:"string"},color:{type:"string"},placeRefs:{type:"array",items:{type:"object",properties:{id:{type:"string"},geohash:{type:"string"}},required:["id","geohash"]},default:[]},createdAt:{type:"string",format:"date-time"},updatedAt:{type:"string",format:"date-time"}},required:["id","title","placeRefs","createdAt"]},cS={name:"places",builder:function(t){function e(t,e){return`${t.substring(0,2)}/${t.substring(2,4)}/${e}`}t.declareType("place",aS),t.declareType("list",lS);const n={async getAll(){const e=await t.getAll("_lists/");return e?Object.values(e):[]},async get(e){const n=`_lists/${e}`;return t.getObject(n)},async create(e,n,i){const r=`_lists/${e}`;let s=await t.getObject(r);const o=(new Date).toISOString();return s?(s.title=n,i&&(s.color=i),s.updatedAt=o):s={id:e,title:n,color:i,placeRefs:[],createdAt:o,updatedAt:o},await t.storeObject("list",r,s),s},async delete(e){await t.remove(`_lists/${e}`)},async addPlace(e,n,i){const r=`_lists/${e}`,s=await t.getObject(r);if(!s)throw new Error(`List not found: ${e}`);return-1===s.placeRefs.findIndex(t=>t.id===n)&&(s.placeRefs.push({id:n,geohash:i}),s.updatedAt=(new Date).toISOString(),await t.storeObject("list",r,s)),s},async removePlace(e,n){const i=`_lists/${e}`,r=await t.getObject(i);if(!r)throw new Error(`List not found: ${e}`);const s=r.placeRefs.findIndex(t=>t.id===n);return-1!==s&&(r.placeRefs.splice(s,1),r.updatedAt=(new Date).toISOString(),await t.storeObject("list",i,r)),r},async initDefaults(){const t=[{id:"to-go",title:"Want to go",color:"#2e9e4f"},{id:"to-do",title:"To do",color:"#2a7fff"}];for(const e of t)awLine truncated
+1
View File
@@ -0,0 +1 @@
:root{--default-list-color: #fc3;--hover-bg: #f8f9fa;--sidebar-width: 350px;--link-color: #2a7fff;--link-color-visited: #6a4fbf}html,body{height:100%;overscroll-behavior:none;-webkit-text-size-adjust:100%;-webkit-tap-highlight-color:transparent}button{-webkit-tap-highlight-color:transparent}body{margin:0;font-family:Noto Sans,sans-serif;font-size:16px;color:#333}#root,#ember-testing{height:100%}.map-container{background:#f8f9fa;-webkit-tap-highlight-color:transparent;outline:none;position:absolute;inset:0}#rs-widget-container{position:absolute;top:60px;right:10px;z-index:4000;display:none}#rs-widget-container.visible{display:block}.rs-backdrop{position:fixed;inset:0;z-index:3999}.app-header{position:absolute;top:0;left:0;right:0;height:60px;padding:.5rem 1rem;z-index:3000;pointer-events:none;display:grid;grid-template-columns:1fr auto 1fr;grid-template-areas:"search chips user";align-items:center;gap:1rem}@media(max-width:768px){.app-header{padding:.5rem .5rem 0;height:auto;grid-template-columns:1fr auto;grid-template-areas:"search user" "chips chips";row-gap:8px}}.header-left,.header-right,.header-center{pointer-events:auto}.header-left{display:flex;align-items:center;grid-area:search;justify-self:start;width:100%}@media not (max-width:768px){.header-left{min-width:300px;max-width:400px}}@media not (max-width:768px){.header-left{min-width:300px;max-width:350px}}.header-right{grid-area:user;justify-self:end}.header-center{grid-area:chips;display:flex;justify-content:center;min-width:0}@media not (max-width:768px){.header-center .category-chips-scroll{width:auto;max-width:100%}}@media(max-width:768px){.header-center{width:100%;overflow:hidden;justify-content:start}.header-center.searching{display:none}}.btn-press{transition:transform .1s}.btn-press:active{transform:scale(.95)}.user-btn{background:none;border:none;cursor:pointer;padding:0}.user-avatar-placeholder{width:40px;height:40px;background:#2a3743;border-radius:50%;display:flex;align-items:center;justify-content:center;box-shadow:0 2px 5px #0003}.user-menu-container{position:relative}.user-menu-popover{position:absolute;top:100%;right:0;margin-top:10px;width:280px;background:#fff;border-radius:8px;box-shadow:0 4px 12px #00000026;padding:1rem;z-index:3001}.menu-backdrop{position:fixed;inset:0;z-index:3000}.user-status{padding-bottom:1rem;margin-bottom:1rem;border-bottom:1px solid #eee;color:#666;font-size:.9rem}.account-list{list-style:none;padding:0;margin:0}.account-item{display:flex;justify-content:space-between;align-items:center;padding:.75rem 0}.account-item.disabled{opacity:.5;pointer-events:none}.account-info{display:flex;align-items:center;gap:.75rem;font-weight:500}.btn-text{background:none;border:none;padding:0;font-weight:600;cursor:pointer;font-size:.9rem}.text-primary{color:var(--link-color)}.text-danger{color:#dc3545}.btn-text:hover{text-decoration:underline}.sidebar{position:absolute;top:0;left:0;bottom:0;width:var(--sidebar-width);background:#fff;z-index:3100;box-shadow:2px 0 5px #0000001a;display:flex;flex-direction:column;overflow:hidden}.sidebar.app-menu-pane{z-index:3200}@media(max-width:768px){.sidebar.app-menu-pane{width:100%;right:0;border-radius:16px 16px 0 0;height:66vh;top:auto;bottom:0}}.sidebar-header{padding:1rem;border-bottom:1px solid #eee;display:flex;justify-content:space-between;align-items:center}.sidebar-header h2{margin:0;font-size:1.2rem;display:flex;align-items:center;gap:.5rem}.sidebar-content{padding:1rem;overflow-y:auto;-webkit-overflow-scrolling:touch;flex:1;min-height:0;touch-action:pan-y;overscroll-behavior:contain}.app-menu-pane .sidebar-content{padding-top:.5rem}.app-menu{list-style:none;padding:0;margin:0 -1rem}.app-menu button{width:100%;display:flex;align-items:center;gap:.8rem;padding:1rem 1rem 1rem 1.4rem;background:none;border:none;color:#333;cursor:pointer;text-align:left;font-size:.95rem;font-family:inherit;transition:background-color .2s}.app-menu button:hover{background-color:var(--hover-bg)}.app-menu .icon{color:#666;width:20px;height:20px}.sidebar-content details{margin:0 -1rem}.sidebar-content details summary{list-style:none;display:flex;align-items:center;gap:.8rem;padding:1rem 1rem 1rem 1.4rem;cursor:pointer;font-size:.95rem;color:#333;transition:background-color .2s}.sidebar-content details summary::-webkit-details-marker{display:none}.sidebar-content details summary:hover{background-color:var(--hover-bg)}.sidebar-content details summary .icon{width:20px;height:20px}.sidebar-content details summary:after{content:"";width:20px;height:20px;background-image:url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='%23666' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='9 18 15 12 9 6'/%3E%3C/svg%3E");background-size:20px 20px;background-repeat:no-repeat;background-position:center;margin-left:auto;transition:transform .2s ease}.sidebar-content details[open] summary:after{tLine truncated
-2
View File
@@ -1,2 +0,0 @@
const t=Object.freeze(Object.defineProperty({__proto__:null,get default(){return $V}},Symbol.toStringTag,{value:"Module"}));!function(){const t=document.createElement("link").relList;if(!(t&&t.supports&&t.supports("modulepreload"))){for(const t of document.querySelectorAll('link[rel="modulepreload"]'))e(t);new MutationObserver(t=>{for(const n of t)if("childList"===n.type)for(const t of n.addedNodes)"LINK"===t.tagName&&"modulepreload"===t.rel&&e(t)}).observe(document,{childList:!0,subtree:!0})}function e(t){if(t.ep)return;t.ep=!0;const e=function(t){const e={};return t.integrity&&(e.integrity=t.integrity),t.referrerPolicy&&(e.referrerPolicy=t.referrerPolicy),"use-credentials"===t.crossOrigin?e.credentials="include":"anonymous"===t.crossOrigin?e.credentials="omit":e.credentials="same-origin",e}(t);fetch(t.href,e)}}();const e="object"==typeof self&&null!==self&&self.Object===Object&&"undefined"!=typeof Window&&self.constructor===Window&&"object"==typeof document&&null!==document&&self.document===document&&"object"==typeof location&&null!==location&&self.location===location&&"object"==typeof history&&null!==history&&self.history===history&&"object"==typeof navigator&&null!==navigator&&self.navigator===navigator&&"string"==typeof navigator.userAgent,n=e?self:null,i=e?self.location:null,r=e?self.history:null,s=e?self.navigator.userAgent:"Lynx (textmode)",o=!(!e||"object"!=typeof chrome||"object"==typeof opera),a=!!e&&/Firefox|FxiOS/.test(s),l=Object.freeze(Object.defineProperty({__proto__:null,hasDOM:e,history:r,isChrome:o,isFirefox:a,location:i,userAgent:s,window:n},Symbol.toStringTag,{value:"Module"}));function c(t){return t&&t.Object===Object?t:void 0}const h=c((u="object"==typeof global&&global)&&void 0===u.nodeType?u:void 0)||c("object"==typeof self&&self)||c("object"==typeof window&&window)||"undefined"!=typeof mainContext&&mainContext||new Function("return this")();var u;const d=function(t,e){return void 0===e?{imports:t,exports:t,lookup:t}:{imports:e.imports||t,exports:e.exports||t,lookup:e.lookup||t}}(h,h.Ember);function p(){return d.lookup}function f(t){d.lookup=t}const g={ENABLE_OPTIONAL_FEATURES:!1,EXTEND_PROTOTYPES:{Array:!1},LOG_STACKTRACE_ON_DEPRECATION:!0,LOG_VERSION:!0,RAISE_ON_DEPRECATION:!1,STRUCTURED_PROFILE:!1,_DEBUG_RENDER_TREE:!1,_ALL_DEPRECATIONS_ENABLED:!1,_OVERRIDE_DEPRECATION_VERSION:null,_DEFAULT_ASYNC_OBSERVERS:!1,_RERENDER_LOOP_LIMIT:1e3,EMBER_LOAD_HOOKS:{},FEATURES:{}};function m(){return g}(t=>{if("object"!=typeof t||null===t)return;for(let i in t){if(!Object.prototype.hasOwnProperty.call(t,i)||"EXTEND_PROTOTYPES"===i||"EMBER_LOAD_HOOKS"===i)continue;let e=g[i];g[i]=!0===e?!1!==t[i]:!1===e?!0===t[i]:t[i]}let{EMBER_LOAD_HOOKS:e}=t;if("object"==typeof e&&null!==e)for(let i in e){if(!Object.prototype.hasOwnProperty.call(e,i))continue;let t=e[i];Array.isArray(t)&&(g.EMBER_LOAD_HOOKS[i]=t.filter(t=>"function"==typeof t))}let{FEATURES:n}=t;if("object"==typeof n&&null!==n)for(let i in n)Object.prototype.hasOwnProperty.call(n,i)&&(g.FEATURES[i]=!0===n[i])})(h.EmberENV);let y=()=>{},_=()=>{},v=!1;function b(){return v}function w(t){v=Boolean(t)}let x=()=>{};const{toString:S}=Object.prototype,{toString:k}=Function.prototype,{isArray:C}=Array,{keys:E}=Object,{stringify:T}=JSON,R=100,P=/^[\w$]+$/;function I(t){return"number"==typeof t&&2===arguments.length?this:M(t,0)}function M(t,e,n){let i=!1;switch(typeof t){case"undefined":return"undefined";case"object":if(null===t)return"null";if(C(t)){i=!0;break}if(t.toString===S||void 0===t.toString)break;return t.toString();case"function":return t.toString===k?t.name?`[Function:${t.name}]`:"[Function]":t.toString();case"string":return T(t);default:return t.toString()}if(void 0===n)n=new WeakSet;else if(n.has(t))return"[Circular]";return n.add(t),i?function(t,e,n){if(e>4)return"[Array]";let i="[";for(let r=0;r<t.length;r++){if(i+=0===r?" ":", ",r>=R){i+=`... ${t.length-R} more items`;break}i+=M(t[r],e,n)}return i+=" ]",i}(t,e+1,n):function(t,e,n){if(e>4)return"[Object]";let i="{",r=E(t);for(let s=0;s<r.length;s++){if(i+=0===s?" ":", ",s>=R){i+=`... ${r.length-R} more keys`;break}let o=r[s];i+=`${O(String(o))}: ${M(t[o],e,n)}`}return i+=" }",i}(t,e+1,n)}function O(t){return P.test(t)?t:T(t)}function A(t){let e=t.lookup("renderer:-dom");if(!e)throw new Error("BUG: owner is missing renderer");return e.debugRenderTree.capture()}const F=()=>{};let L=F,z=F,j=F,D=F,N=F,U=F,B=F,q=F,$=function(){return arguments[arguments.length-1]};function V(){}const G=Object.freeze(Object.defineProperty({__proto__:null,_warnIfUsingStrippedFeatureFlags:void 0,assert:y,captureRenderTree:A,debug:j,debugFreeze:N,debugSeal:D,deprecate:V,deprecateFunc:$,getDebugFunction:q,info:L,inspect:I,isTesting:b,registerDeprecationHandler:_,registerWarnHandler:x,runInDebug:U,setDebugFunction:B,setTesting:w,warn:z},Symbol.toStringTag,{value:"Module"}));function W(t){let e=Object.create(null);e[t]=1;for(let n in e)if(n===t)return n;return t}function H(t){return null!==t&&("object"==typeof t||"fLine truncated
/*! For license information please see remotestorage.js.LICENSE.txt */const Xx=Wx(Yx),Zx="0123456789bcdefghjkmnpqrstuvwxyz";class Jx{static encode(t,e,n){if(void 0===n){for(let n=1;n<=12;n++){const i=Jx.encode(t,e,n),r=Jx.decode(i);if(r.lat==t&&r.lon==e)return i}n=12}if(t=Number(t),e=Number(e),n=Number(n),isNaN(t)||isNaN(e)||isNaN(n))throw new Error("Invalid geohash");let i=0,r=0,s=!0,o="",a=-90,l=90,c=-180,h=180;for(;o.length<n;){if(s){const t=(c+h)/2;e>=t?(i=2*i+1,c=t):(i*=2,h=t)}else{const e=(a+l)/2;t>=e?(i=2*i+1,a=e):(i*=2,l=e)}s=!s,5==++r&&(o+=Zx.charAt(i),r=0,i=0)}return o}static decode(t){const e=Jx.bounds(t),n=e.sw.lat,i=e.sw.lon,r=e.ne.lat,s=e.ne.lon;let o=(n+r)/2,a=(i+s)/2;return o=o.toFixed(Math.floor(2-Math.log(r-n)/Math.LN10)),a=a.toFixed(Math.floor(2-Math.log(s-i)/Math.LN10)),{lat:Number(o),lon:Number(a)}}static bounds(t){if(0==t.length)throw new Error("Invalid geohash");t=t.toLowerCase();let e=!0,n=-90,i=90,r=-180,s=180;for(let o=0;o<t.length;o++){const a=t.charAt(o),l=Zx.indexOf(a);if(-1==l)throw new Error("Invalid geohash");for(let t=4;t>=0;t--){const o=l>>t&1;if(e){const t=(r+s)/2;1==o?r=t:s=t}else{const t=(n+i)/2;1==o?n=t:i=t}e=!e}}return{sw:{lat:n,lon:r},ne:{lat:i,lon:s}}}static adjacent(t,e){if(t=t.toLowerCase(),e=e.toLowerCase(),0==t.length)throw new Error("Invalid geohash");if(-1=="nsew".indexOf(e))throw new Error("Invalid direction");const n=t.slice(-1);let i=t.slice(0,-1);const r=t.length%2;return-1!={n:["prxz","bcfguvyz"],s:["028b","0145hjnp"],e:["bcfguvyz","prxz"],w:["0145hjnp","028b"]}[e][r].indexOf(n)&&""!=i&&(i=Jx.adjacent(i,e)),i+Zx.charAt({n:["p0r21436x8zb9dcf5h7kjnmqesgutwvy","bc01fg45238967deuvhjyznpkmstqrwx"],s:["14365h7k9dcfesgujnmqp0r2twvyx8zb","238967debc01fg45kmstqrwxuvhjyznp"],e:["bc01fg45238967deuvhjyznpkmstqrwx","p0r21436x8zb9dcf5h7kjnmqesgutwvy"],w:["238967debc01fg45kmstqrwxuvhjyznp","14365h7k9dcfesgujnmqp0r2twvyx8zb"]}[e][r].indexOf(n))}static neighbours(t){return{n:Jx.adjacent(t,"n"),ne:Jx.adjacent(Jx.adjacent(t,"n"),"e"),e:Jx.adjacent(t,"e"),se:Jx.adjacent(Jx.adjacent(t,"s"),"e"),s:Jx.adjacent(t,"s"),sw:Jx.adjacent(Jx.adjacent(t,"s"),"w"),w:Jx.adjacent(t,"w"),nw:Jx.adjacent(Jx.adjacent(t,"n"),"w")}}}const Qx="0123456789ABCDEFGHJKMNPQRSTVWXYZ",tS=0xffffffffffff;var eS;!function(t){t.Base32IncorrectEncoding="B32_ENC_INVALID",t.DecodeTimeInvalidCharacter="DEC_TIME_CHAR",t.DecodeTimeValueMalformed="DEC_TIME_MALFORMED",t.EncodeTimeNegative="ENC_TIME_NEG",t.EncodeTimeSizeExceeded="ENC_TIME_SIZE_EXCEED",t.EncodeTimeValueMalformed="ENC_TIME_MALFORMED",t.PRNGDetectFailure="PRNG_DETECT",t.ULIDInvalid="ULID_INVALID",t.Unexpected="UNEXPECTED",t.UUIDInvalid="UUID_INVALID"}(eS||(eS={}));class nS extends Error{constructor(t,e){super(`${e} (${t})`),this.name="ULIDError",this.code=t}}function iS(t){const e=Math.floor(32*t())%32;return Qx.charAt(e)}const rS={type:"object",properties:{id:{type:"string"},title:{type:"string"},lat:{type:"number"},lon:{type:"number"},geohash:{type:"string"},zoom:{type:"number"},url:{type:"string"},osmId:{type:"string"},osmType:{type:"string"},osmTags:{type:"object",additionalProperties:{type:"string"}},description:{type:"string"},tags:{type:"array",items:{type:"string"},default:[]},createdAt:{type:"string",format:"date-time"},updatedAt:{type:"string",format:"date-time"}},required:["id","title","lat","lon","geohash","createdAt"]},sS={type:"object",properties:{id:{type:"string"},title:{type:"string"},color:{type:"string"},placeRefs:{type:"array",items:{type:"object",properties:{id:{type:"string"},geohash:{type:"string"}},required:["id","geohash"]},default:[]},createdAt:{type:"string",format:"date-time"},updatedAt:{type:"string",format:"date-time"}},required:["id","title","placeRefs","createdAt"]},oS={name:"places",builder:function(t){function e(t,e){return`${t.substring(0,2)}/${t.substring(2,4)}/${e}`}t.declareType("place",rS),t.declareType("list",sS);const n={async getAll(){const e=await t.getAll("_lists/");return e?Object.values(e):[]},async get(e){const n=`_lists/${e}`;return t.getObject(n)},async create(e,n,i){const r=`_lists/${e}`;let s=await t.getObject(r);const o=(new Date).toISOString();return s?(s.title=n,i&&(s.color=i),s.updatedAt=o):s={id:e,title:n,color:i,placeRefs:[],createdAt:o,updatedAt:o},await t.storeObject("list",r,s),s},async delete(e){await t.remove(`_lists/${e}`)},async addPlace(e,n,i){const r=`_lists/${e}`,s=await t.getObject(r);if(!s)throw new Error(`List not found: ${e}`);return-1===s.placeRefs.findIndex(t=>t.id===n)&&(s.placeRefs.push({id:n,geohash:i}),s.updatedAt=(new Date).toISOString(),await t.storeObject("list",r,s)),s},async removePlace(e,n){const i=`_lists/${e}`,r=await t.getObject(i);if(!r)throw new Error(`List not found: ${e}`);const s=r.placeRefs.findIndex(t=>t.id===n);return-1!==s&&(r.placeRefs.splice(s,1),r.updatedAt=(new Date).toISOString(),await t.storeObject("list",i,r)),r},async initDefaults(){const t=[{id:"to-go",title:"Want to go",color:"#2e9e4f"},{id:"to-do",title:"To do",color:"#2a7fff"}];for(const e of t)awLine truncated
@@ -1 +1 @@
import{c as e,g as a,a as s,s as t,A as r,C as o,b as n,D as i,L as l,N as b,d as c,P as p,e as d,S as m,T as u,_ as g,f as y,h as _,i as O,j as S,k as f,l as C,m as P,n as h,o as v,p as j,q as E,r as M,t as D,u as N,v as V,w as A,x as w,y as T,z as I,B as x,E as R,F as k,G as F,H as L,I as z,J as G,K as U,M as B,O as W,Q as Y,R as H,U as J,V as K,W as X,X as q,Y as Q,Z,$,a0 as ee,a1 as ae,a2 as se,a3 as te,a4 as re,a5 as oe,a6 as ne,a7 as ie,a8 as le,a9 as be,aa as pe,ab as de,ac as me,ad as ue,ae as ge,af as ye,ag as _e,ah as Oe,ai as Se,aj as fe,ak as Ce,al as Pe,am as he,an as ve,ao as je,ap as Ee,aq as Me,ar as De,as as Ne,at as Ve,au as Ae,av as we,aw as Te,ax as Ie,ay as xe,az as Re,aA as ke,aB as Fe,aC as Le,aD as ze,aE as Ge,aF as Ue,aG as Be,aH as We,aI as Ye,aJ as He,aK as Je,aL as Ke,aM as Xe,aN as qe,aO as Qe,aP as Ze,aQ as $e,aR as ea,aS as aa,aT as sa,aU as ta,aV as ra,aW as oa,aX as na,aY as ia,aZ as la,a_ as ba,a$ as pa,b0 as da,b1 as ma,b2 as ua,b3 as ga,b4 as ya,b5 as _a,b6 as Oa,b7 as Sa,b8 as fa,b9 as Ca,ba as Pa,bb as ha,bc as va,bd as ja,be as Ea,bf as Ma,bg as Da,bh as Na,bi as Va,bj as Aa,bk as wa,bl as Ta,bm as Ia,bn as xa,bo as Ra,bp as ka,bq as Fa,br as La,bs as za,bt as Ga,bu as Ua,bv as Ba,bw as Wa,bx as Ya,by as Ha,bz as Ja,bA as Ka,bB as Xa,bC as qa,bD as Qa,bE as Za,bF as $a,bG as es,bH as as,bI as ss,bJ as ts,bK as rs,bL as os,bM as ns,bN as is,bO as ls,bP as bs,bQ as cs,bR as ps,bS as ds,bT as ms,bU as us}from"./main-gEUnNw-L.js";export{bV as Application,bW as ApplicationNamespace,bX as Array,bY as ArrayProxy,bZ as Controller,b_ as Debug,b$ as EmberDestroyable,c0 as EmberObject,c1 as EnumerableMutable,cc as GlimmerComponent,cd as GlimmerManager,ce as GlimmerReference,cf as GlimmerRuntime,cg as GlimmerUtil,ch as GlimmerValidator,c2 as Instrumentation,c0 as Object,c3 as ObjectCore,c4 as ObjectEvented,c5 as ObjectObservable,c6 as ObjectPromiseProxyMixin,c7 as ObjectProxy,ca as Owner,c8 as Runloop,c9 as Service,cb as VERSION}from"./main-gEUnNw-L.js";const gs=new WeakMap,ys=Object.freeze(Object.defineProperty({__proto__:null,ASYNC_OBSERVERS:r,ComputedDescriptor:o,ComputedProperty:n,DEBUG_INJECTION_FUNCTIONS:i,Libraries:l,NAMESPACES:b,NAMESPACES_BY_ID:c,PROPERTY_DID_CHANGE:p,PROXY_CONTENT:d,SYNC_OBSERVERS:m,TrackedDescriptor:u,_getPath:g,_getProp:y,_setProp:_,activateObserver:O,addArrayObserver:S,addListener:f,addNamespace:C,addObserver:P,alias:h,arrayContentDidChange:v,arrayContentWillChange:j,autoComputed:E,beginPropertyChanges:M,cached:(...s)=>{const[t,r,o]=s,n=new WeakMap,i=o.get;o.get=function(){return n.has(this)||n.set(this,e(i.bind(this))),a(n.get(this))}},changeProperties:D,computed:N,createCache:e,defineDecorator:V,defineProperty:A,defineValue:w,deprecateProperty:function(e,a,r){Object.defineProperty(e,a,{configurable:!0,enumerable:!1,set(e){t(this,r,e)},get(){return s(this,r)}})},descriptorForDecorator:T,descriptorForProperty:I,eachProxyArrayDidChange:function(e,a,s,t){let r=gs.get(e);void 0!==r&&r.arrayDidChange(e,a,s,t)},eachProxyArrayWillChange:function(e,a,s,t){let r=gs.get(e);void 0!==r&&r.arrayWillChange(e,a,s,t)},endPropertyChanges:x,expandProperties:R,findNamespace:k,findNamespaces:F,flushAsyncObservers:L,get:s,getCachedValueFor:z,getProperties:G,getValue:a,hasListeners:U,hasUnknownProperty:B,inject:W,isClassicDecorator:Y,isComputed:H,isConst:J,isElementDescriptor:K,isNamespaceSearchDisabled:X,libraries:q,makeComputedDecorator:Q,markObjectAsDirty:Z,nativeDescDecorator:$,notifyPropertyChange:ee,objectAt:ae,on:se,processAllNamespaces:te,processNamespace:re,removeArrayObserver:oe,removeListener:ne,removeNamespace:ie,removeObserver:le,replace:be,replaceInNativeArray:pe,revalidateObservers:de,sendEvent:me,set:t,setClassicDecorator:ue,setNamespaceSearchDisabled:ge,setProperties:ye,setUnprocessedMixins:_e,tagForObject:Oe,tagForProperty:Se,tracked:fe,trySet:Ce},Symbol.toStringTag,{value:"Module"})),_s=Object.freeze(Object.defineProperty({__proto__:null,Input:Pe,Textarea:he,capabilities:ve,default:je,getComponentTemplate:Ee,setComponentManager:Me,setComponentTemplate:De},Symbol.toStringTag,{value:"Module"})),Os=Object.freeze(Object.defineProperty({__proto__:null,cacheFor:z,guidFor:Ne},Symbol.toStringTag,{value:"Module"})),Ss=Object.freeze(Object.defineProperty({__proto__:null,EventTarget:Ve,Promise:Ae,all:we,allSettled:Te,asap:Ie,async:xe,cast:Re,configure:ke,default:Fe,defer:Le,denodeify:ze,filter:Ge,hash:Ue,hashSettled:Be,map:We,off:Ye,on:He,race:Je,reject:Ke,resolve:Xe,rethrow:qe},Symbol.toStringTag,{value:"Module"})),fs=Object.freeze(Object.defineProperty({__proto__:null,default:Qe},Symbol.toStringTag,{value:"Module"})),Cs=Object.freeze(Object.defineProperty({__proto__:null,ENV:Ze,context:$e,getENV:ea,getLookup:aa,global:sa,setLookup:ta},Symbol.toStringTag,{value:"Module"})),Ps=Object.freeze(Object.defineProperty({__proto__:null,Meta:ra,UNDEFINED:oa,counters:na,meta:ia,peekMeta:la,setMeta:ba},Symbol.toStringTag,{value:"Module"})),hs=Object.freeze(Object.definePropLine truncated
import{c as e,g as a,a as s,s as t,A as r,C as o,b as n,D as i,L as l,N as b,d as c,P as p,e as d,S as m,T as u,_ as g,f as y,h as _,i as O,j as S,k as f,l as C,m as P,n as h,o as v,p as j,q as E,r as M,t as D,u as N,v as V,w as A,x as w,y as T,z as I,B as x,E as R,F as k,G as F,H as L,I as z,J as G,K as U,M as B,O as W,Q as Y,R as H,U as J,V as K,W as X,X as q,Y as Q,Z,$,a0 as ee,a1 as ae,a2 as se,a3 as te,a4 as re,a5 as oe,a6 as ne,a7 as ie,a8 as le,a9 as be,aa as pe,ab as de,ac as me,ad as ue,ae as ge,af as ye,ag as _e,ah as Oe,ai as Se,aj as fe,ak as Ce,al as Pe,am as he,an as ve,ao as je,ap as Ee,aq as Me,ar as De,as as Ne,at as Ve,au as Ae,av as we,aw as Te,ax as Ie,ay as xe,az as Re,aA as ke,aB as Fe,aC as Le,aD as ze,aE as Ge,aF as Ue,aG as Be,aH as We,aI as Ye,aJ as He,aK as Je,aL as Ke,aM as Xe,aN as qe,aO as Qe,aP as Ze,aQ as $e,aR as ea,aS as aa,aT as sa,aU as ta,aV as ra,aW as oa,aX as na,aY as ia,aZ as la,a_ as ba,a$ as pa,b0 as da,b1 as ma,b2 as ua,b3 as ga,b4 as ya,b5 as _a,b6 as Oa,b7 as Sa,b8 as fa,b9 as Ca,ba as Pa,bb as ha,bc as va,bd as ja,be as Ea,bf as Ma,bg as Da,bh as Na,bi as Va,bj as Aa,bk as wa,bl as Ta,bm as Ia,bn as xa,bo as Ra,bp as ka,bq as Fa,br as La,bs as za,bt as Ga,bu as Ua,bv as Ba,bw as Wa,bx as Ya,by as Ha,bz as Ja,bA as Ka,bB as Xa,bC as qa,bD as Qa,bE as Za,bF as $a,bG as es,bH as as,bI as ss,bJ as ts,bK as rs,bL as os,bM as ns,bN as is,bO as ls,bP as bs,bQ as cs,bR as ps,bS as ds,bT as ms,bU as us}from"./main-C4F17h3W.js";export{bV as Application,bW as ApplicationNamespace,bX as Array,bY as ArrayProxy,bZ as Controller,b_ as Debug,b$ as EmberDestroyable,c0 as EmberObject,c1 as EnumerableMutable,cc as GlimmerComponent,cd as GlimmerManager,ce as GlimmerReference,cf as GlimmerRuntime,cg as GlimmerUtil,ch as GlimmerValidator,c2 as Instrumentation,c0 as Object,c3 as ObjectCore,c4 as ObjectEvented,c5 as ObjectObservable,c6 as ObjectPromiseProxyMixin,c7 as ObjectProxy,ca as Owner,c8 as Runloop,c9 as Service,cb as VERSION}from"./main-C4F17h3W.js";const gs=new WeakMap,ys=Object.freeze(Object.defineProperty({__proto__:null,ASYNC_OBSERVERS:r,ComputedDescriptor:o,ComputedProperty:n,DEBUG_INJECTION_FUNCTIONS:i,Libraries:l,NAMESPACES:b,NAMESPACES_BY_ID:c,PROPERTY_DID_CHANGE:p,PROXY_CONTENT:d,SYNC_OBSERVERS:m,TrackedDescriptor:u,_getPath:g,_getProp:y,_setProp:_,activateObserver:O,addArrayObserver:S,addListener:f,addNamespace:C,addObserver:P,alias:h,arrayContentDidChange:v,arrayContentWillChange:j,autoComputed:E,beginPropertyChanges:M,cached:(...s)=>{const[t,r,o]=s,n=new WeakMap,i=o.get;o.get=function(){return n.has(this)||n.set(this,e(i.bind(this))),a(n.get(this))}},changeProperties:D,computed:N,createCache:e,defineDecorator:V,defineProperty:A,defineValue:w,deprecateProperty:function(e,a,r){Object.defineProperty(e,a,{configurable:!0,enumerable:!1,set(e){t(this,r,e)},get(){return s(this,r)}})},descriptorForDecorator:T,descriptorForProperty:I,eachProxyArrayDidChange:function(e,a,s,t){let r=gs.get(e);void 0!==r&&r.arrayDidChange(e,a,s,t)},eachProxyArrayWillChange:function(e,a,s,t){let r=gs.get(e);void 0!==r&&r.arrayWillChange(e,a,s,t)},endPropertyChanges:x,expandProperties:R,findNamespace:k,findNamespaces:F,flushAsyncObservers:L,get:s,getCachedValueFor:z,getProperties:G,getValue:a,hasListeners:U,hasUnknownProperty:B,inject:W,isClassicDecorator:Y,isComputed:H,isConst:J,isElementDescriptor:K,isNamespaceSearchDisabled:X,libraries:q,makeComputedDecorator:Q,markObjectAsDirty:Z,nativeDescDecorator:$,notifyPropertyChange:ee,objectAt:ae,on:se,processAllNamespaces:te,processNamespace:re,removeArrayObserver:oe,removeListener:ne,removeNamespace:ie,removeObserver:le,replace:be,replaceInNativeArray:pe,revalidateObservers:de,sendEvent:me,set:t,setClassicDecorator:ue,setNamespaceSearchDisabled:ge,setProperties:ye,setUnprocessedMixins:_e,tagForObject:Oe,tagForProperty:Se,tracked:fe,trySet:Ce},Symbol.toStringTag,{value:"Module"})),_s=Object.freeze(Object.defineProperty({__proto__:null,Input:Pe,Textarea:he,capabilities:ve,default:je,getComponentTemplate:Ee,setComponentManager:Me,setComponentTemplate:De},Symbol.toStringTag,{value:"Module"})),Os=Object.freeze(Object.defineProperty({__proto__:null,cacheFor:z,guidFor:Ne},Symbol.toStringTag,{value:"Module"})),Ss=Object.freeze(Object.defineProperty({__proto__:null,EventTarget:Ve,Promise:Ae,all:we,allSettled:Te,asap:Ie,async:xe,cast:Re,configure:ke,default:Fe,defer:Le,denodeify:ze,filter:Ge,hash:Ue,hashSettled:Be,map:We,off:Ye,on:He,race:Je,reject:Ke,resolve:Xe,rethrow:qe},Symbol.toStringTag,{value:"Module"})),fs=Object.freeze(Object.defineProperty({__proto__:null,default:Qe},Symbol.toStringTag,{value:"Module"})),Cs=Object.freeze(Object.defineProperty({__proto__:null,ENV:Ze,context:$e,getENV:ea,getLookup:aa,global:sa,setLookup:ta},Symbol.toStringTag,{value:"Module"})),Ps=Object.freeze(Object.defineProperty({__proto__:null,Meta:ra,UNDEFINED:oa,counters:na,meta:ia,peekMeta:la,setMeta:ba},Symbol.toStringTag,{value:"Module"})),hs=Object.freeze(Object.definePropLine truncated
+2 -2
View File
@@ -39,8 +39,8 @@
<meta name="msapplication-TileColor" content="#F6E9A6">
<meta name="msapplication-TileImage" content="/icons/icon-144.png">
<script type="module" crossorigin src="/assets/main-gEUnNw-L.js"></script>
<link rel="stylesheet" crossorigin href="/assets/main-BOfcjRke.css">
<script type="module" crossorigin src="/assets/main-C4F17h3W.js"></script>
<link rel="stylesheet" crossorigin href="/assets/main-CKp1bFPU.css">
</head>
<body>
</body>
+182
View 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');
});
});
+13 -14
View File
@@ -64,25 +64,24 @@ module('Acceptance | navigation', function (hooks) {
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 backStub = sinon.stub(window.history, 'back');
try {
await visit('/search?lat=1&lon=1');
assert.strictEqual(currentURL(), '/search?lat=1&lon=1');
await visit('/search?lat=1&lon=1');
assert.strictEqual(currentURL(), '/search?lat=1&lon=1');
await click('.place-item');
assert.ok(currentURL().includes('/place/'), 'Navigated to place');
assert.true(mapUi.returnToSearch, 'Flag returnToSearch is set');
await click('.place-item');
assert.ok(currentURL().includes('/place/'), 'Navigated to place');
assert.true(mapUi.returnToSearch, 'Flag returnToSearch is set');
// Click the back button in the sidebar
await click('.back-btn');
// Click the back button in the sidebar
await click('.back-btn');
assert.true(backStub.calledOnce, 'window.history.back() was called');
} finally {
backStub.restore();
}
assert.strictEqual(
currentURL(),
'/search?lat=1&lon=1',
'Returned to search results'
);
});
test('closing the sidebar resets the returnToSearch flag', async function (assert) {
+136
View 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');
});
});
+63
View File
@@ -155,4 +155,67 @@ module('Acceptance | search', function (hooks) {
assert.dom('.places-list li').exists({ count: 1 });
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 { 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 Service from '@ember/service';
module('Integration | Component | app-header', function (hooks) {
setupRenderingTest(hooks);
test('it renders the search box', async function (assert) {
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(
<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('.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');
});
});
@@ -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 }));
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']);
});
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
View 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'
);
});
});
+41
View File
@@ -251,4 +251,45 @@ module('Unit | Service | osm', function (hooks) {
[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
View 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);
});
});