Compare commits
21 Commits
0af9d9f16d
...
feature/10
| Author | SHA1 | Date | |
|---|---|---|---|
|
bf12305600
|
|||
|
2734f08608
|
|||
|
2aa59f9384
|
|||
|
bcf8ca4255
|
|||
|
20f63065ad
|
|||
|
39a7ec3595
|
|||
| 32dfa3a30f | |||
|
64ccc694d3
|
|||
|
87e2380ef6
|
|||
| 66c31b19f1 | |||
|
55aecbd699
|
|||
|
ccaa56b78f
|
|||
|
d30375707a
|
|||
|
53300b92f5
|
|||
|
c37f794eea
|
|||
|
4bc92bb7cc
|
|||
|
9f48d7b264
|
|||
| bbd3bf47c6 | |||
|
59e3d91071
|
|||
|
348b721876
|
|||
|
3d982a6a7c
|
@@ -5,6 +5,7 @@ import { action } from '@ember/object';
|
|||||||
import { on } from '@ember/modifier';
|
import { on } from '@ember/modifier';
|
||||||
import Icon from '#components/icon';
|
import Icon from '#components/icon';
|
||||||
import UserMenu from '#components/user-menu';
|
import UserMenu from '#components/user-menu';
|
||||||
|
import SearchBox from '#components/search-box';
|
||||||
|
|
||||||
export default class AppHeaderComponent extends Component {
|
export default class AppHeaderComponent extends Component {
|
||||||
@service storage;
|
@service storage;
|
||||||
@@ -24,19 +25,21 @@ export default class AppHeaderComponent extends Component {
|
|||||||
<header class="app-header">
|
<header class="app-header">
|
||||||
<div class="header-left">
|
<div class="header-left">
|
||||||
<button
|
<button
|
||||||
class="icon-btn"
|
class="menu-btn btn-press"
|
||||||
type="button"
|
type="button"
|
||||||
aria-label="Menu"
|
aria-label="Menu"
|
||||||
{{on "click" @onToggleMenu}}
|
{{on "click" @onToggleMenu}}
|
||||||
>
|
>
|
||||||
<Icon @name="menu" @size={{24}} @color="#333" />
|
<Icon @name="menu" @size={{24}} @color="#333" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<SearchBox />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="header-right">
|
<div class="header-right">
|
||||||
<div class="user-menu-container">
|
<div class="user-menu-container">
|
||||||
<button
|
<button
|
||||||
class="user-btn"
|
class="user-btn btn-press"
|
||||||
type="button"
|
type="button"
|
||||||
aria-label="User Menu"
|
aria-label="User Menu"
|
||||||
{{on "click" this.toggleUserMenu}}
|
{{on "click" this.toggleUserMenu}}
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ export default class MapComponent extends Component {
|
|||||||
// Default view settings
|
// Default view settings
|
||||||
let center = [14.21683569, 27.060114248];
|
let center = [14.21683569, 27.060114248];
|
||||||
let zoom = 2.661;
|
let zoom = 2.661;
|
||||||
|
let restoredFromStorage = false;
|
||||||
|
|
||||||
// Try to restore from localStorage
|
// Try to restore from localStorage
|
||||||
try {
|
try {
|
||||||
@@ -82,6 +83,7 @@ export default class MapComponent extends Component {
|
|||||||
) {
|
) {
|
||||||
center = parsed.center;
|
center = parsed.center;
|
||||||
zoom = parsed.zoom;
|
zoom = parsed.zoom;
|
||||||
|
restoredFromStorage = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -99,7 +101,7 @@ export default class MapComponent extends Component {
|
|||||||
layers: [openfreemap, bookmarkLayer],
|
layers: [openfreemap, bookmarkLayer],
|
||||||
view: view,
|
view: view,
|
||||||
controls: defaultControls({
|
controls: defaultControls({
|
||||||
zoom: false,
|
zoom: true,
|
||||||
rotate: true,
|
rotate: true,
|
||||||
attribution: true,
|
attribution: true,
|
||||||
}),
|
}),
|
||||||
@@ -108,6 +110,10 @@ export default class MapComponent extends Component {
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Initialize the UI service with the map center
|
||||||
|
const initialCenter = toLonLat(view.getCenter());
|
||||||
|
this.mapUi.updateCenter(initialCenter[1], initialCenter[0]);
|
||||||
|
|
||||||
apply(this.mapInstance, 'https://tiles.openfreemap.org/styles/liberty');
|
apply(this.mapInstance, 'https://tiles.openfreemap.org/styles/liberty');
|
||||||
|
|
||||||
this.searchOverlayElement = document.createElement('div');
|
this.searchOverlayElement = document.createElement('div');
|
||||||
@@ -153,9 +159,6 @@ export default class MapComponent extends Component {
|
|||||||
`;
|
`;
|
||||||
element.appendChild(this.crosshairElement);
|
element.appendChild(this.crosshairElement);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// Geolocation Pulse Overlay
|
// Geolocation Pulse Overlay
|
||||||
this.locationOverlayElement = document.createElement('div');
|
this.locationOverlayElement = document.createElement('div');
|
||||||
this.locationOverlayElement.className = 'search-pulse blue';
|
this.locationOverlayElement.className = 'search-pulse blue';
|
||||||
@@ -243,6 +246,7 @@ export default class MapComponent extends Component {
|
|||||||
const coordinates = geolocation.getPosition();
|
const coordinates = geolocation.getPosition();
|
||||||
const accuracyGeometry = geolocation.getAccuracyGeometry();
|
const accuracyGeometry = geolocation.getAccuracyGeometry();
|
||||||
const accuracy = geolocation.getAccuracy();
|
const accuracy = geolocation.getAccuracy();
|
||||||
|
console.debug('Geolocation change:', { coordinates, accuracy });
|
||||||
|
|
||||||
if (!coordinates) return;
|
if (!coordinates) return;
|
||||||
|
|
||||||
@@ -307,7 +311,8 @@ export default class MapComponent extends Component {
|
|||||||
this.mapInstance.getView().animate(viewOptions);
|
this.mapInstance.getView().animate(viewOptions);
|
||||||
};
|
};
|
||||||
|
|
||||||
locateBtn.addEventListener('click', () => {
|
const startLocating = () => {
|
||||||
|
console.debug('Getting current geolocation...');
|
||||||
// 1. Clear any previous session
|
// 1. Clear any previous session
|
||||||
stopLocating();
|
stopLocating();
|
||||||
|
|
||||||
@@ -331,7 +336,9 @@ export default class MapComponent extends Component {
|
|||||||
locateTimeout = setTimeout(() => {
|
locateTimeout = setTimeout(() => {
|
||||||
stopLocating();
|
stopLocating();
|
||||||
}, 10000);
|
}, 10000);
|
||||||
});
|
};
|
||||||
|
|
||||||
|
locateBtn.addEventListener('click', startLocating);
|
||||||
|
|
||||||
const locateControl = new Control({
|
const locateControl = new Control({
|
||||||
element: locateElement,
|
element: locateElement,
|
||||||
@@ -340,6 +347,11 @@ export default class MapComponent extends Component {
|
|||||||
this.mapInstance.addLayer(geolocationLayer);
|
this.mapInstance.addLayer(geolocationLayer);
|
||||||
this.mapInstance.addControl(locateControl);
|
this.mapInstance.addControl(locateControl);
|
||||||
|
|
||||||
|
// Auto-locate on first visit (if not restored from storage and on home page)
|
||||||
|
if (!restoredFromStorage && this.router.currentRouteName === 'index') {
|
||||||
|
startLocating();
|
||||||
|
}
|
||||||
|
|
||||||
this.mapInstance.on('singleclick', this.handleMapClick);
|
this.mapInstance.on('singleclick', this.handleMapClick);
|
||||||
|
|
||||||
// Load places when map moves
|
// Load places when map moves
|
||||||
@@ -363,17 +375,31 @@ export default class MapComponent extends Component {
|
|||||||
if (!this.mapInstance) return;
|
if (!this.mapInstance) return;
|
||||||
|
|
||||||
// Remove existing DragPan interactions
|
// Remove existing DragPan interactions
|
||||||
this.mapInstance.getInteractions().getArray().slice().forEach((interaction) => {
|
this.mapInstance
|
||||||
if (interaction instanceof DragPan) {
|
.getInteractions()
|
||||||
this.mapInstance.removeInteraction(interaction);
|
.getArray()
|
||||||
}
|
.slice()
|
||||||
});
|
.forEach((interaction) => {
|
||||||
|
if (interaction instanceof DragPan) {
|
||||||
|
this.mapInstance.removeInteraction(interaction);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Add new DragPan with current setting
|
// Add new DragPan with current setting
|
||||||
const kinetic = this.settings.mapKinetic
|
const kinetic = this.settings.mapKinetic
|
||||||
? new Kinetic(-0.005, 0.05, 100)
|
? new Kinetic(-0.005, 0.05, 100)
|
||||||
: false;
|
: false;
|
||||||
|
|
||||||
|
// Fix for "sticky" touches on mobile:
|
||||||
|
// If we're on mobile (width <= 768) AND using kinetic,
|
||||||
|
// we increase the minimum velocity required to trigger kinetic panning.
|
||||||
|
// This prevents slow drags from being interpreted as a "throw"
|
||||||
|
if (this.settings.mapKinetic && window.innerWidth <= 768) {
|
||||||
|
// Default minVelocity is 0.05. We bump it up significantly.
|
||||||
|
// This means the user has to really "flick" the map to get inertia.
|
||||||
|
kinetic.minVelocity_ = 0.25;
|
||||||
|
}
|
||||||
|
|
||||||
this.mapInstance.addInteraction(
|
this.mapInstance.addInteraction(
|
||||||
new DragPan({
|
new DragPan({
|
||||||
kinetic: kinetic,
|
kinetic: kinetic,
|
||||||
@@ -623,18 +649,29 @@ export default class MapComponent extends Component {
|
|||||||
handleMapMove = async () => {
|
handleMapMove = async () => {
|
||||||
if (!this.mapInstance) return;
|
if (!this.mapInstance) return;
|
||||||
|
|
||||||
|
const view = this.mapInstance.getView();
|
||||||
|
const center = toLonLat(view.getCenter());
|
||||||
|
this.mapUi.updateCenter(center[1], center[0]);
|
||||||
|
|
||||||
// If in creation mode, update the coordinates in the service AND the URL
|
// If in creation mode, update the coordinates in the service AND the URL
|
||||||
if (this.mapUi.isCreating) {
|
if (this.mapUi.isCreating) {
|
||||||
// Calculate coordinates under the crosshair element
|
// Calculate coordinates under the crosshair element
|
||||||
// We need the pixel position of the crosshair relative to the map viewport
|
// We need the pixel position of the crosshair relative to the map viewport
|
||||||
// The crosshair is positioned via CSS, so we can use getBoundingClientRect
|
// The crosshair is positioned via CSS, so we can use getBoundingClientRect
|
||||||
const mapRect = this.mapInstance.getTargetElement().getBoundingClientRect();
|
const mapRect = this.mapInstance
|
||||||
|
.getTargetElement()
|
||||||
|
.getBoundingClientRect();
|
||||||
const crosshairRect = this.crosshairElement.getBoundingClientRect();
|
const crosshairRect = this.crosshairElement.getBoundingClientRect();
|
||||||
|
|
||||||
const centerX = crosshairRect.left + crosshairRect.width / 2 - mapRect.left;
|
const centerX =
|
||||||
const centerY = crosshairRect.top + crosshairRect.height / 2 - mapRect.top;
|
crosshairRect.left + crosshairRect.width / 2 - mapRect.left;
|
||||||
|
const centerY =
|
||||||
|
crosshairRect.top + crosshairRect.height / 2 - mapRect.top;
|
||||||
|
|
||||||
const coordinate = this.mapInstance.getCoordinateFromPixel([centerX, centerY]);
|
const coordinate = this.mapInstance.getCoordinateFromPixel([
|
||||||
|
centerX,
|
||||||
|
centerY,
|
||||||
|
]);
|
||||||
const center = toLonLat(coordinate);
|
const center = toLonLat(coordinate);
|
||||||
|
|
||||||
const lat = parseFloat(center[1].toFixed(6));
|
const lat = parseFloat(center[1].toFixed(6));
|
||||||
@@ -723,6 +760,13 @@ export default class MapComponent extends Component {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Require Zoom >= 17 for generic map searches
|
||||||
|
// This prevents accidental searches when interacting with the map at a high level
|
||||||
|
const currentZoom = this.mapInstance.getView().getZoom();
|
||||||
|
if (currentZoom < 16) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const coords = toLonLat(event.coordinate);
|
const coords = toLonLat(event.coordinate);
|
||||||
const [lon, lat] = coords;
|
const [lon, lat] = coords;
|
||||||
|
|
||||||
@@ -752,10 +796,9 @@ export default class MapComponent extends Component {
|
|||||||
const queryParams = {
|
const queryParams = {
|
||||||
lat: lat.toFixed(6),
|
lat: lat.toFixed(6),
|
||||||
lon: lon.toFixed(6),
|
lon: lon.toFixed(6),
|
||||||
|
q: null, // Clear q to force spatial search
|
||||||
|
selected: selectedFeatureName || null,
|
||||||
};
|
};
|
||||||
if (selectedFeatureName) {
|
|
||||||
queryParams.q = selectedFeatureName;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.router.transitionTo('search', { queryParams });
|
this.router.transitionTo('search', { queryParams });
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import Component from '@glimmer/component';
|
|||||||
import { fn } from '@ember/helper';
|
import { fn } from '@ember/helper';
|
||||||
import { on } from '@ember/modifier';
|
import { on } from '@ember/modifier';
|
||||||
import { humanizeOsmTag } from '../utils/format-text';
|
import { humanizeOsmTag } from '../utils/format-text';
|
||||||
|
import { getLocalizedName } from '../utils/osm';
|
||||||
import Icon from '../components/icon';
|
import Icon from '../components/icon';
|
||||||
import PlaceEditForm from './place-edit-form';
|
import PlaceEditForm from './place-edit-form';
|
||||||
|
|
||||||
@@ -20,12 +21,7 @@ export default class PlaceDetails extends Component {
|
|||||||
}
|
}
|
||||||
|
|
||||||
get name() {
|
get name() {
|
||||||
return (
|
return this.place.title || getLocalizedName(this.tags) || 'Unnamed Place';
|
||||||
this.place.title ||
|
|
||||||
this.tags.name ||
|
|
||||||
this.tags['name:en'] ||
|
|
||||||
'Unnamed Place'
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@action
|
@action
|
||||||
@@ -129,7 +125,7 @@ export default class PlaceDetails extends Component {
|
|||||||
const lat = this.place.lat;
|
const lat = this.place.lat;
|
||||||
const lon = this.place.lon;
|
const lon = this.place.lon;
|
||||||
if (!lat || !lon) return '';
|
if (!lat || !lon) return '';
|
||||||
return `${lat}, ${lon}`;
|
return `${Number(lat).toFixed(6)}, ${Number(lon).toFixed(6)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
get osmUrl() {
|
get osmUrl() {
|
||||||
@@ -274,7 +270,11 @@ export default class PlaceDetails extends Component {
|
|||||||
<p class="content-with-icon">
|
<p class="content-with-icon">
|
||||||
<Icon @name="map" />
|
<Icon @name="map" />
|
||||||
<span>
|
<span>
|
||||||
<a href={{this.gmapsUrl}} target="_blank" rel="noopener noreferrer">
|
<a
|
||||||
|
href={{this.gmapsUrl}}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
Google Maps
|
Google Maps
|
||||||
</a>
|
</a>
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import or from 'ember-truth-helpers/helpers/or';
|
|||||||
import PlaceDetails from './place-details';
|
import PlaceDetails from './place-details';
|
||||||
import Icon from './icon';
|
import Icon from './icon';
|
||||||
import humanizeOsmTag from '../helpers/humanize-osm-tag';
|
import humanizeOsmTag from '../helpers/humanize-osm-tag';
|
||||||
|
import { getLocalizedName } from '../utils/osm';
|
||||||
|
|
||||||
export default class PlacesSidebar extends Component {
|
export default class PlacesSidebar extends Component {
|
||||||
@service storage;
|
@service storage;
|
||||||
@@ -22,8 +23,10 @@ export default class PlacesSidebar extends Component {
|
|||||||
if (lat && lon) {
|
if (lat && lon) {
|
||||||
this.router.transitionTo('place.new', { queryParams: { lat, lon } });
|
this.router.transitionTo('place.new', { queryParams: { lat, lon } });
|
||||||
} else {
|
} else {
|
||||||
// Fallback (shouldn't happen in search context)
|
// Fallback (shouldn't happen in search context)
|
||||||
this.router.transitionTo('place.new', { queryParams: { lat: 0, lon: 0 } });
|
this.router.transitionTo('place.new', {
|
||||||
|
queryParams: { lat: 0, lon: 0 },
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,8 +88,7 @@ export default class PlacesSidebar extends Component {
|
|||||||
} else {
|
} else {
|
||||||
// It's a fresh POI -> Save it
|
// It's a fresh POI -> Save it
|
||||||
const placeData = {
|
const placeData = {
|
||||||
title:
|
title: getLocalizedName(place.osmTags, 'Untitled Place'),
|
||||||
place.osmTags.name || place.osmTags['name:en'] || 'Untitled Place',
|
|
||||||
lat: place.lat,
|
lat: place.lat,
|
||||||
lon: place.lon,
|
lon: place.lon,
|
||||||
tags: [],
|
tags: [],
|
||||||
@@ -152,7 +154,7 @@ export default class PlacesSidebar extends Component {
|
|||||||
{{on "click" this.clearSelection}}
|
{{on "click" this.clearSelection}}
|
||||||
><Icon @name="arrow-left" @size={{20}} @color="#333" /></button>
|
><Icon @name="arrow-left" @size={{20}} @color="#333" /></button>
|
||||||
{{else}}
|
{{else}}
|
||||||
<h2><Icon @name="target" @size={{20}} @color="#ea4335" /> Nearby</h2>
|
<h2><Icon @name="target" @size={{20}} @color="#ea4335" /> Nearby</h2>
|
||||||
{{/if}}
|
{{/if}}
|
||||||
<button type="button" class="close-btn" {{on "click" @onClose}}><Icon
|
<button type="button" class="close-btn" {{on "click" @onClose}}><Icon
|
||||||
@name="x"
|
@name="x"
|
||||||
@@ -184,14 +186,16 @@ export default class PlacesSidebar extends Component {
|
|||||||
place.osmTags.name:en
|
place.osmTags.name:en
|
||||||
"Unnamed Place"
|
"Unnamed Place"
|
||||||
}}</div>
|
}}</div>
|
||||||
<div class="place-type">{{humanizeOsmTag (or
|
<div class="place-type">{{humanizeOsmTag
|
||||||
place.osmTags.amenity
|
(or
|
||||||
place.osmTags.shop
|
place.osmTags.amenity
|
||||||
place.osmTags.tourism
|
place.osmTags.shop
|
||||||
place.osmTags.leisure
|
place.osmTags.tourism
|
||||||
place.osmTags.historic
|
place.osmTags.leisure
|
||||||
"Point of Interest"
|
place.osmTags.historic
|
||||||
)}}</div>
|
"Point of Interest"
|
||||||
|
)
|
||||||
|
}}</div>
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
{{/each}}
|
{{/each}}
|
||||||
|
|||||||
178
app/components/search-box.gjs
Normal file
178
app/components/search-box.gjs
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
import Component from '@glimmer/component';
|
||||||
|
import { service } from '@ember/service';
|
||||||
|
import { tracked } from '@glimmer/tracking';
|
||||||
|
import { action } from '@ember/object';
|
||||||
|
import { on } from '@ember/modifier';
|
||||||
|
import { fn } from '@ember/helper';
|
||||||
|
import { debounce } from '@ember/runloop';
|
||||||
|
import Icon from '#components/icon';
|
||||||
|
|
||||||
|
export default class SearchBoxComponent extends Component {
|
||||||
|
@service photon;
|
||||||
|
@service router;
|
||||||
|
@service mapUi;
|
||||||
|
@service map; // Assuming we might need map context, but mostly we use router
|
||||||
|
|
||||||
|
@tracked query = '';
|
||||||
|
@tracked results = [];
|
||||||
|
@tracked isFocused = false;
|
||||||
|
@tracked isLoading = false;
|
||||||
|
|
||||||
|
get showPopover() {
|
||||||
|
return this.isFocused && this.results.length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
handleInput(event) {
|
||||||
|
this.query = event.target.value;
|
||||||
|
if (this.query.length < 2) {
|
||||||
|
this.results = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
debounce(this, this.performSearch, 300);
|
||||||
|
}
|
||||||
|
|
||||||
|
async performSearch() {
|
||||||
|
if (this.query.length < 2) return;
|
||||||
|
|
||||||
|
this.isLoading = true;
|
||||||
|
try {
|
||||||
|
// Use map center if available for location bias
|
||||||
|
let lat, lon;
|
||||||
|
if (this.mapUi.currentCenter) {
|
||||||
|
({ lat, lon } = this.mapUi.currentCenter);
|
||||||
|
}
|
||||||
|
const results = await this.photon.search(this.query, lat, lon);
|
||||||
|
this.results = results;
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Search failed', e);
|
||||||
|
this.results = [];
|
||||||
|
} finally {
|
||||||
|
this.isLoading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
handleFocus() {
|
||||||
|
this.isFocused = true;
|
||||||
|
if (this.query.length >= 2 && this.results.length === 0) {
|
||||||
|
this.performSearch();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
handleBlur() {
|
||||||
|
// Delay hiding so clicks on results can register
|
||||||
|
setTimeout(() => {
|
||||||
|
this.isFocused = false;
|
||||||
|
}, 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
handleSubmit(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!this.query) return;
|
||||||
|
|
||||||
|
let queryParams = { q: this.query, selected: 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 });
|
||||||
|
this.isFocused = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
selectResult(place) {
|
||||||
|
this.query = place.title;
|
||||||
|
this.results = []; // Hide popover
|
||||||
|
|
||||||
|
// If it has an OSM ID, go to place details
|
||||||
|
if (place.osmId) {
|
||||||
|
// Format: osm:node:123
|
||||||
|
// place.osmType is already normalized to 'node', 'way', or 'relation' by PhotonService
|
||||||
|
const id = `osm:${place.osmType}:${place.osmId}`;
|
||||||
|
this.router.transitionTo('place', id);
|
||||||
|
} else {
|
||||||
|
// Just a location (e.g. from Photon without OSM ID, though unlikely for Photon)
|
||||||
|
// Or we can treat it as a search query
|
||||||
|
this.router.transitionTo('search', {
|
||||||
|
queryParams: {
|
||||||
|
q: place.title,
|
||||||
|
lat: place.lat,
|
||||||
|
lon: place.lon,
|
||||||
|
selected: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
clear() {
|
||||||
|
this.query = '';
|
||||||
|
this.results = [];
|
||||||
|
this.router.transitionTo('index'); // Or stay on current page?
|
||||||
|
// Usually clear just clears the input.
|
||||||
|
}
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="search-box">
|
||||||
|
<form class="search-form" {{on "submit" this.handleSubmit}}>
|
||||||
|
<div class="search-icon">
|
||||||
|
<Icon @name="search" @size={{18}} @color="#666" />
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="search"
|
||||||
|
class="search-input"
|
||||||
|
placeholder="Search places..."
|
||||||
|
aria-label="Search places"
|
||||||
|
value={{this.query}}
|
||||||
|
{{on "input" this.handleInput}}
|
||||||
|
{{on "focus" this.handleFocus}}
|
||||||
|
{{on "blur" this.handleBlur}}
|
||||||
|
autocomplete="off"
|
||||||
|
/>
|
||||||
|
{{#if this.query}}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="search-clear-btn"
|
||||||
|
{{on "click" this.clear}}
|
||||||
|
aria-label="Clear"
|
||||||
|
>
|
||||||
|
<Icon @name="x" @size={{16}} @color="#999" />
|
||||||
|
</button>
|
||||||
|
{{/if}}
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{{#if this.showPopover}}
|
||||||
|
<div class="search-results-popover">
|
||||||
|
<ul class="search-results-list">
|
||||||
|
{{#each this.results as |result|}}
|
||||||
|
<li>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="search-result-item"
|
||||||
|
{{on "click" (fn this.selectResult result)}}
|
||||||
|
>
|
||||||
|
<div class="result-icon">
|
||||||
|
<Icon @name="map-pin" @size={{16}} @color="#666" />
|
||||||
|
</div>
|
||||||
|
<div class="result-info">
|
||||||
|
<span class="result-title">{{result.title}}</span>
|
||||||
|
{{#if result.description}}
|
||||||
|
<span class="result-desc">{{result.description}}</span>
|
||||||
|
{{/if}}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
{{/each}}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{{/if}}
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
}
|
||||||
@@ -46,7 +46,7 @@ export default class SettingsPane extends Component {
|
|||||||
</option>
|
</option>
|
||||||
<option
|
<option
|
||||||
value="false"
|
value="false"
|
||||||
selected={{if (not this.settings.mapKinetic) "selected"}}
|
selected={{unless this.settings.mapKinetic "selected"}}
|
||||||
>
|
>
|
||||||
Off
|
Off
|
||||||
</option>
|
</option>
|
||||||
@@ -62,7 +62,10 @@ export default class SettingsPane extends Component {
|
|||||||
{{#each this.settings.overpassApis as |api|}}
|
{{#each this.settings.overpassApis as |api|}}
|
||||||
<option
|
<option
|
||||||
value={{api.url}}
|
value={{api.url}}
|
||||||
selected={{if (eq api.url this.settings.overpassApi) "selected"}}
|
selected={{if
|
||||||
|
(eq api.url this.settings.overpassApi)
|
||||||
|
"selected"
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{{api.name}}
|
{{api.name}}
|
||||||
</option>
|
</option>
|
||||||
@@ -73,24 +76,45 @@ export default class SettingsPane extends Component {
|
|||||||
<section class="settings-section">
|
<section class="settings-section">
|
||||||
<h3>About</h3>
|
<h3>About</h3>
|
||||||
<p>
|
<p>
|
||||||
<strong>Marco</strong> (as in <a
|
<strong>Marco</strong>
|
||||||
href="https://en.wikipedia.org/wiki/Marco_Polo"
|
(as in
|
||||||
target="_blank" rel="noopener">Marco Polo</a>) is an unhosted maps application
|
<a
|
||||||
that respects your privacy and choices.
|
href="https://en.wikipedia.org/wiki/Marco_Polo"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener"
|
||||||
|
>Marco Polo</a>) is an unhosted maps application that respects your
|
||||||
|
privacy and choices.
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
Connect your own <a href="https://remotestorage.io/"
|
Connect your own
|
||||||
target="_blank" rel="noopener">remote storage</a> to sync place bookmarks across
|
<a
|
||||||
apps and devices.
|
href="https://remotestorage.io/"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener"
|
||||||
|
>remote storage</a>
|
||||||
|
to sync place bookmarks across apps and devices.
|
||||||
</p>
|
</p>
|
||||||
<ul class="link-list">
|
<ul class="link-list">
|
||||||
<li>
|
<li>
|
||||||
<a href="https://gitea.kosmos.org/raucao/marco" target="_blank" rel="noopener">
|
<a
|
||||||
|
href="https://gitea.kosmos.org/raucao/marco"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener"
|
||||||
|
>
|
||||||
Source Code
|
Source Code
|
||||||
</a> (<a href="https://en.wikipedia.org/wiki/GNU_Affero_General_Public_License" target="_blank" rel="noopener">AGPL</a>)
|
</a>
|
||||||
|
(<a
|
||||||
|
href="https://en.wikipedia.org/wiki/GNU_Affero_General_Public_License"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener"
|
||||||
|
>AGPL</a>)
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<a href="https://openstreetmap.org/copyright" target="_blank" rel="noopener">
|
<a
|
||||||
|
href="https://openstreetmap.org/copyright"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener"
|
||||||
|
>
|
||||||
Map Data © OpenStreetMap
|
Map Data © OpenStreetMap
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
10
app/controllers/search.js
Normal file
10
app/controllers/search.js
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import Controller from '@ember/controller';
|
||||||
|
|
||||||
|
export default class SearchController extends Controller {
|
||||||
|
queryParams = ['lat', 'lon', 'q', 'selected'];
|
||||||
|
|
||||||
|
lat = null;
|
||||||
|
lon = null;
|
||||||
|
q = null;
|
||||||
|
selected = null;
|
||||||
|
}
|
||||||
@@ -9,7 +9,11 @@ export default class PlaceRoute extends Route {
|
|||||||
async model(params) {
|
async model(params) {
|
||||||
const id = params.place_id;
|
const id = params.place_id;
|
||||||
|
|
||||||
if (id.startsWith('osm:node:') || id.startsWith('osm:way:')) {
|
if (
|
||||||
|
id.startsWith('osm:node:') ||
|
||||||
|
id.startsWith('osm:way:') ||
|
||||||
|
id.startsWith('osm:relation:')
|
||||||
|
) {
|
||||||
const [, type, osmId] = id.split(':');
|
const [, type, osmId] = id.split(':');
|
||||||
console.debug(`Fetching explicit OSM ${type}:`, osmId);
|
console.debug(`Fetching explicit OSM ${type}:`, osmId);
|
||||||
return this.loadOsmPlace(osmId, type);
|
return this.loadOsmPlace(osmId, type);
|
||||||
@@ -56,11 +60,14 @@ export default class PlaceRoute extends Route {
|
|||||||
deactivate() {
|
deactivate() {
|
||||||
// Clear the pin when leaving the route
|
// Clear the pin when leaving the route
|
||||||
this.mapUi.clearSelection();
|
this.mapUi.clearSelection();
|
||||||
|
// Reset the "return to search" flag so it doesn't persist to subsequent navigations
|
||||||
|
this.mapUi.returnToSearch = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
async loadOsmPlace(id, type = null) {
|
async loadOsmPlace(id, type = null) {
|
||||||
try {
|
try {
|
||||||
const poi = await this.osm.getPoiById(id, type);
|
// Use the direct OSM API fetch instead of Overpass for single object lookups
|
||||||
|
const poi = await this.osm.fetchOsmObject(id, type);
|
||||||
if (poi) {
|
if (poi) {
|
||||||
console.debug('Found OSM POI:', poi);
|
console.debug('Found OSM POI:', poi);
|
||||||
return poi;
|
return poi;
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { getDistance } from '../utils/geo';
|
|||||||
|
|
||||||
export default class SearchRoute extends Route {
|
export default class SearchRoute extends Route {
|
||||||
@service osm;
|
@service osm;
|
||||||
|
@service photon;
|
||||||
@service mapUi;
|
@service mapUi;
|
||||||
@service storage;
|
@service storage;
|
||||||
@service router;
|
@service router;
|
||||||
@@ -13,50 +14,76 @@ export default class SearchRoute extends Route {
|
|||||||
lat: { refreshModel: true },
|
lat: { refreshModel: true },
|
||||||
lon: { refreshModel: true },
|
lon: { refreshModel: true },
|
||||||
q: { refreshModel: true },
|
q: { refreshModel: true },
|
||||||
|
selected: { refreshModel: true },
|
||||||
};
|
};
|
||||||
|
|
||||||
async model(params) {
|
async model(params) {
|
||||||
// If no coordinates, we can't search
|
const lat = params.lat ? parseFloat(params.lat) : null;
|
||||||
if (!params.lat || !params.lon) {
|
const lon = params.lon ? parseFloat(params.lon) : null;
|
||||||
return [];
|
let pois = [];
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
|
||||||
|
// 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) {
|
||||||
|
const searchRadius = 50; // Default radius
|
||||||
|
|
||||||
const lat = parseFloat(params.lat);
|
// Fetch POIs from Overpass
|
||||||
const lon = parseFloat(params.lon);
|
pois = await this.osm.getNearbyPois(lat, lon, searchRadius);
|
||||||
const searchRadius = params.q ? 30 : 50;
|
|
||||||
|
|
||||||
// Fetch POIs
|
// Get cached/saved places in search radius
|
||||||
let pois = await this.osm.getNearbyPois(lat, lon, searchRadius);
|
const localMatches = this.storage.savedPlaces.filter((p) => {
|
||||||
|
const dist = getDistance(lat, lon, p.lat, p.lon);
|
||||||
|
return dist <= searchRadius;
|
||||||
|
});
|
||||||
|
|
||||||
// Get cached/saved places in search radius
|
// Merge local matches
|
||||||
const localMatches = this.storage.savedPlaces.filter((p) => {
|
localMatches.forEach((local) => {
|
||||||
const dist = getDistance(lat, lon, p.lat, p.lon);
|
const exists = pois.find(
|
||||||
return dist <= searchRadius;
|
(poi) =>
|
||||||
});
|
(local.osmId && poi.osmId === local.osmId) ||
|
||||||
|
(poi.id && poi.id === local.id)
|
||||||
|
);
|
||||||
|
|
||||||
// Add local matches to the list if they aren't already there
|
if (!exists) {
|
||||||
// We use osmId to deduplicate if possible
|
pois.push(local);
|
||||||
localMatches.forEach((local) => {
|
}
|
||||||
const exists = pois.find(
|
});
|
||||||
(poi) =>
|
|
||||||
(local.osmId && poi.osmId === local.osmId) ||
|
|
||||||
(poi.id && poi.id === local.id)
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!exists) {
|
// Sort by distance from click
|
||||||
pois.push(local);
|
pois = pois
|
||||||
}
|
.map((p) => {
|
||||||
});
|
return {
|
||||||
|
...p,
|
||||||
// Sort by distance from click
|
_distance: getDistance(lat, lon, p.lat, p.lon),
|
||||||
pois = pois
|
};
|
||||||
.map((p) => {
|
})
|
||||||
return {
|
.sort((a, b) => a._distance - b._distance);
|
||||||
...p,
|
}
|
||||||
_distance: getDistance(lat, lon, p.lat, p.lon),
|
|
||||||
};
|
|
||||||
})
|
|
||||||
.sort((a, b) => a._distance - b._distance);
|
|
||||||
|
|
||||||
// Check if any of these are already bookmarked
|
// Check if any of these are already bookmarked
|
||||||
// We resolve them to the bookmark version if they exist
|
// We resolve them to the bookmark version if they exist
|
||||||
@@ -69,18 +96,24 @@ export default class SearchRoute extends Route {
|
|||||||
}
|
}
|
||||||
|
|
||||||
afterModel(model, transition) {
|
afterModel(model, transition) {
|
||||||
const { q } = transition.to.queryParams;
|
const { q, selected } = transition.to.queryParams;
|
||||||
|
|
||||||
// Heuristic Match Logic (ported from MapComponent)
|
// Heuristic Match Logic (ported from MapComponent)
|
||||||
if (q && model.length > 0) {
|
// If 'selected' is provided (from map click), try to find that specific feature.
|
||||||
|
// If 'q' is provided (from text search), try to find an exact match to auto-select.
|
||||||
|
const targetName = selected || q;
|
||||||
|
|
||||||
|
if (targetName && model.length > 0) {
|
||||||
let matchedPlace = null;
|
let matchedPlace = null;
|
||||||
|
|
||||||
// 1. Exact Name Match
|
// 1. Exact Name Match
|
||||||
matchedPlace = model.find(
|
matchedPlace = model.find(
|
||||||
(p) => p.osmTags && (p.osmTags.name === q || p.osmTags['name:en'] === q)
|
(p) =>
|
||||||
|
p.osmTags &&
|
||||||
|
(p.osmTags.name === targetName || p.osmTags['name:en'] === targetName)
|
||||||
);
|
);
|
||||||
|
|
||||||
// 2. High Proximity Match (<= 10m)
|
// 2. High Proximity Match (<= 10m) - Only if we don't have a name match
|
||||||
// Note: MapComponent had logic for <=20m + type match.
|
// Note: MapComponent had logic for <=20m + type match.
|
||||||
// We might want to pass the 'type' in queryParams if we want to be that precise.
|
// We might want to pass the 'type' in queryParams if we want to be that precise.
|
||||||
// For now, let's stick to name or very close proximity.
|
// For now, let's stick to name or very close proximity.
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ export default class MapUiService extends Service {
|
|||||||
@tracked isSearching = false;
|
@tracked isSearching = false;
|
||||||
@tracked isCreating = false;
|
@tracked isCreating = false;
|
||||||
@tracked creationCoordinates = null;
|
@tracked creationCoordinates = null;
|
||||||
|
@tracked returnToSearch = false;
|
||||||
|
@tracked currentCenter = null;
|
||||||
|
|
||||||
selectPlace(place) {
|
selectPlace(place) {
|
||||||
this.selectedPlace = place;
|
this.selectedPlace = place;
|
||||||
@@ -37,4 +39,8 @@ export default class MapUiService extends Service {
|
|||||||
updateCreationCoordinates(lat, lon) {
|
updateCreationCoordinates(lat, lon) {
|
||||||
this.creationCoordinates = { lat, lon };
|
this.creationCoordinates = { lat, lon };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
updateCenter(lat, lon) {
|
||||||
|
this.currentCenter = { lat, lon };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import Service, { service } from '@ember/service';
|
import Service, { service } from '@ember/service';
|
||||||
|
import { getLocalizedName } from '../utils/osm';
|
||||||
|
|
||||||
export default class OsmService extends Service {
|
export default class OsmService extends Service {
|
||||||
@service settings;
|
@service settings;
|
||||||
@@ -61,7 +62,7 @@ out center;
|
|||||||
|
|
||||||
normalizePoi(poi) {
|
normalizePoi(poi) {
|
||||||
return {
|
return {
|
||||||
title: poi.tags?.name || poi.tags?.['name:en'] || 'Untitled Place',
|
title: getLocalizedName(poi.tags),
|
||||||
lat: poi.lat || poi.center?.lat,
|
lat: poi.lat || poi.center?.lat,
|
||||||
lon: poi.lon || poi.center?.lon,
|
lon: poi.lon || poi.center?.lon,
|
||||||
url: poi.tags?.website,
|
url: poi.tags?.website,
|
||||||
@@ -123,4 +124,115 @@ out center;
|
|||||||
if (!data.elements[0]) return null;
|
if (!data.elements[0]) return null;
|
||||||
return this.normalizePoi(data.elements[0]);
|
return this.normalizePoi(data.elements[0]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fetchOsmObject(osmId, osmType) {
|
||||||
|
if (!osmId || !osmType) return null;
|
||||||
|
|
||||||
|
let url;
|
||||||
|
if (osmType === 'node') {
|
||||||
|
url = `https://www.openstreetmap.org/api/0.6/node/${osmId}.json`;
|
||||||
|
} else if (osmType === 'way') {
|
||||||
|
url = `https://www.openstreetmap.org/api/0.6/way/${osmId}/full.json`;
|
||||||
|
} else if (osmType === 'relation') {
|
||||||
|
url = `https://www.openstreetmap.org/api/0.6/relation/${osmId}/full.json`;
|
||||||
|
} else {
|
||||||
|
console.error('Unknown OSM type:', osmType);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await this.fetchWithRetry(url);
|
||||||
|
if (!res.ok) {
|
||||||
|
if (res.status === 410) {
|
||||||
|
console.warn('OSM object has been deleted');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
throw new Error(`OSM API request failed: ${res.status}`);
|
||||||
|
}
|
||||||
|
const data = await res.json();
|
||||||
|
return this.normalizeOsmApiData(data.elements, osmId, osmType);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to fetch OSM object:', e);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
normalizeOsmApiData(elements, targetId, targetType) {
|
||||||
|
if (!elements || elements.length === 0) return null;
|
||||||
|
|
||||||
|
const mainElement = elements.find(
|
||||||
|
(el) => String(el.id) === String(targetId) && el.type === targetType
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!mainElement) return null;
|
||||||
|
|
||||||
|
let lat = mainElement.lat;
|
||||||
|
let lon = mainElement.lon;
|
||||||
|
|
||||||
|
// If it's a way, calculate center from nodes
|
||||||
|
if (targetType === 'way' && mainElement.nodes) {
|
||||||
|
const nodeMap = new Map();
|
||||||
|
elements.forEach((el) => {
|
||||||
|
if (el.type === 'node') {
|
||||||
|
nodeMap.set(el.id, [el.lon, el.lat]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const coords = mainElement.nodes
|
||||||
|
.map((id) => nodeMap.get(id))
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
if (coords.length > 0) {
|
||||||
|
// Simple average center
|
||||||
|
const sumLat = coords.reduce((sum, c) => sum + c[1], 0);
|
||||||
|
const sumLon = coords.reduce((sum, c) => sum + c[0], 0);
|
||||||
|
lat = sumLat / coords.length;
|
||||||
|
lon = sumLon / coords.length;
|
||||||
|
}
|
||||||
|
} else if (targetType === 'relation' && mainElement.members) {
|
||||||
|
// Find all nodes that are part of this relation (directly or via ways)
|
||||||
|
const allNodes = [];
|
||||||
|
const nodeMap = new Map();
|
||||||
|
elements.forEach((el) => {
|
||||||
|
if (el.type === 'node') {
|
||||||
|
nodeMap.set(el.id, el);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
mainElement.members.forEach((member) => {
|
||||||
|
if (member.type === 'node') {
|
||||||
|
const node = nodeMap.get(member.ref);
|
||||||
|
if (node) allNodes.push(node);
|
||||||
|
} else if (member.type === 'way') {
|
||||||
|
const way = elements.find(
|
||||||
|
(el) => el.type === 'way' && el.id === member.ref
|
||||||
|
);
|
||||||
|
if (way && way.nodes) {
|
||||||
|
way.nodes.forEach((nodeId) => {
|
||||||
|
const node = nodeMap.get(nodeId);
|
||||||
|
if (node) allNodes.push(node);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (allNodes.length > 0) {
|
||||||
|
const sumLat = allNodes.reduce((sum, n) => sum + n.lat, 0);
|
||||||
|
const sumLon = allNodes.reduce((sum, n) => sum + n.lon, 0);
|
||||||
|
lat = sumLat / allNodes.length;
|
||||||
|
lon = sumLon / allNodes.length;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: getLocalizedName(mainElement.tags),
|
||||||
|
lat,
|
||||||
|
lon,
|
||||||
|
url: mainElement.tags?.website,
|
||||||
|
osmId: String(mainElement.id),
|
||||||
|
osmType: mainElement.type,
|
||||||
|
osmTags: mainElement.tags || {},
|
||||||
|
description: mainElement.tags?.description,
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
108
app/services/photon.js
Normal file
108
app/services/photon.js
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
import Service from '@ember/service';
|
||||||
|
|
||||||
|
export default class PhotonService extends Service {
|
||||||
|
baseUrl = 'https://photon.komoot.io/api/';
|
||||||
|
|
||||||
|
async search(query, lat, lon, limit = 10) {
|
||||||
|
if (!query || query.length < 2) return [];
|
||||||
|
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
q: query,
|
||||||
|
limit: String(limit),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (lat && lon) {
|
||||||
|
params.append('lat', parseFloat(lat).toFixed(4));
|
||||||
|
params.append('lon', parseFloat(lon).toFixed(4));
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = `${this.baseUrl}?${params.toString()}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await this.fetchWithRetry(url);
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`Photon request failed with status ${res.status}`);
|
||||||
|
}
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
if (!data.features) return [];
|
||||||
|
|
||||||
|
return data.features.map((f) => this.normalizeFeature(f));
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Photon search error:', e);
|
||||||
|
// Return empty array on error so UI doesn't break
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
normalizeFeature(feature) {
|
||||||
|
const props = feature.properties || {};
|
||||||
|
const geom = feature.geometry || {};
|
||||||
|
const coords = geom.coordinates || [];
|
||||||
|
|
||||||
|
// Photon returns [lon, lat] for Point geometries
|
||||||
|
const lon = coords[0];
|
||||||
|
const lat = coords[1];
|
||||||
|
|
||||||
|
// Construct a description from address fields
|
||||||
|
// Priority: name -> street -> city -> state -> country
|
||||||
|
const addressParts = [];
|
||||||
|
if (props.street)
|
||||||
|
addressParts.push(
|
||||||
|
props.housenumber
|
||||||
|
? `${props.street} ${props.housenumber}`
|
||||||
|
: props.street
|
||||||
|
);
|
||||||
|
if (props.city && props.city !== props.name) addressParts.push(props.city);
|
||||||
|
if (props.state && props.state !== props.city)
|
||||||
|
addressParts.push(props.state);
|
||||||
|
if (props.country) addressParts.push(props.country);
|
||||||
|
|
||||||
|
const description = addressParts.join(', ');
|
||||||
|
const title = props.name || description || 'Unknown Place';
|
||||||
|
|
||||||
|
const osmTypeMap = {
|
||||||
|
N: 'node',
|
||||||
|
W: 'way',
|
||||||
|
R: 'relation',
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
title,
|
||||||
|
lat,
|
||||||
|
lon,
|
||||||
|
osmId: props.osm_id,
|
||||||
|
osmType: osmTypeMap[props.osm_type] || props.osm_type, // 'node', 'way', 'relation'
|
||||||
|
osmTags: props, // Keep all properties as tags for now
|
||||||
|
description: props.name ? description : addressParts.slice(1).join(', '),
|
||||||
|
source: 'photon',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async fetchWithRetry(url, options = {}, retries = 3) {
|
||||||
|
try {
|
||||||
|
// eslint-disable-next-line warp-drive/no-external-request-patterns
|
||||||
|
const res = await fetch(url, options);
|
||||||
|
|
||||||
|
// Retry on 5xx errors or 429 Too Many Requests
|
||||||
|
if (!res.ok && retries > 0 && [502, 503, 504, 429].includes(res.status)) {
|
||||||
|
console.warn(
|
||||||
|
`Photon request failed with ${res.status}. Retrying... (${retries} left)`
|
||||||
|
);
|
||||||
|
// Exponential backoff or fixed delay? Let's do 1s fixed delay for simplicity
|
||||||
|
await new Promise((r) => setTimeout(r, 1000));
|
||||||
|
return this.fetchWithRetry(url, options, retries - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
return res;
|
||||||
|
} catch (e) {
|
||||||
|
// Retry on network errors (fetch throws) except AbortError
|
||||||
|
if (retries > 0 && e.name !== 'AbortError') {
|
||||||
|
console.debug(`Retrying Photon request... (${retries} left)`, e);
|
||||||
|
await new Promise((r) => setTimeout(r, 1000));
|
||||||
|
return this.fetchWithRetry(url, options, retries - 1);
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -28,13 +28,8 @@ export default class SettingsService extends Service {
|
|||||||
const savedKinetic = localStorage.getItem('marco:map-kinetic');
|
const savedKinetic = localStorage.getItem('marco:map-kinetic');
|
||||||
if (savedKinetic !== null) {
|
if (savedKinetic !== null) {
|
||||||
this.mapKinetic = savedKinetic === 'true';
|
this.mapKinetic = savedKinetic === 'true';
|
||||||
} else {
|
|
||||||
// Default: disabled on small screens (mobile), enabled on desktop
|
|
||||||
// We check for typical mobile width (<= 768px)
|
|
||||||
if (typeof window !== 'undefined') {
|
|
||||||
this.mapKinetic = window.innerWidth > 768;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
// Default is true (initialized in class field)
|
||||||
}
|
}
|
||||||
|
|
||||||
updateOverpassApi(url) {
|
updateOverpassApi(url) {
|
||||||
|
|||||||
@@ -5,6 +5,11 @@ body {
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
overscroll-behavior: none; /* Prevent pull-to-refresh on mobile */
|
overscroll-behavior: none; /* Prevent pull-to-refresh on mobile */
|
||||||
-webkit-text-size-adjust: 100%;
|
-webkit-text-size-adjust: 100%;
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
@@ -69,7 +74,20 @@ body {
|
|||||||
pointer-events: auto; /* Re-enable clicks for buttons */
|
pointer-events: auto; /* Re-enable clicks for buttons */
|
||||||
}
|
}
|
||||||
|
|
||||||
.icon-btn {
|
.header-left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-press {
|
||||||
|
transition: transform 0.1s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-press:active {
|
||||||
|
transform: scale(0.95);
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-btn {
|
||||||
background: white;
|
background: white;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
@@ -80,11 +98,6 @@ body {
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
box-shadow: 0 2px 5px rgb(0 0 0 / 20%);
|
box-shadow: 0 2px 5px rgb(0 0 0 / 20%);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: transform 0.1s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.icon-btn:active {
|
|
||||||
transform: scale(0.95);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.user-btn {
|
.user-btn {
|
||||||
@@ -539,22 +552,40 @@ body {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Locate Control */
|
/* Zoom Control - Moved to bottom right above attribution */
|
||||||
|
.ol-zoom {
|
||||||
|
top: auto !important;
|
||||||
|
left: auto !important;
|
||||||
|
bottom: 2.5em;
|
||||||
|
right: 0.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ol-touch .ol-zoom {
|
||||||
|
bottom: 3.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Locate Control - Above Zoom */
|
||||||
.ol-control.ol-locate {
|
.ol-control.ol-locate {
|
||||||
inset: auto 0.5em 2.5em auto;
|
top: auto !important;
|
||||||
|
left: auto !important;
|
||||||
|
bottom: 6.5em;
|
||||||
|
right: 0.5em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ol-touch .ol-control.ol-locate {
|
.ol-touch .ol-control.ol-locate {
|
||||||
inset: auto 0.5em 3.5em auto;
|
bottom: 8.5em;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Rotate Control */
|
/* Rotate Control - Above Locate */
|
||||||
.ol-rotate {
|
.ol-rotate {
|
||||||
inset: auto 0.5em 5em auto;
|
top: auto !important;
|
||||||
|
left: auto !important;
|
||||||
|
bottom: 9em;
|
||||||
|
right: 0.5em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ol-touch .ol-rotate {
|
.ol-touch .ol-rotate {
|
||||||
inset: auto 0.5em 6em auto;
|
bottom: 11.5em;
|
||||||
}
|
}
|
||||||
|
|
||||||
span.icon {
|
span.icon {
|
||||||
@@ -724,3 +755,158 @@ button.create-place {
|
|||||||
padding-bottom: env(safe-area-inset-bottom, 20px);
|
padding-bottom: env(safe-area-inset-bottom, 20px);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Search Box Component */
|
||||||
|
.search-box {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 320px;
|
||||||
|
margin-left: 1rem;
|
||||||
|
z-index: 3002; /* Higher than menu button to be safe */
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.search-box {
|
||||||
|
max-width: 200px; /* Smaller on mobile */
|
||||||
|
margin-left: 0.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-form {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
background: white;
|
||||||
|
border-radius: 24px; /* Pill shape */
|
||||||
|
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.15);
|
||||||
|
padding: 0 0.75rem;
|
||||||
|
height: 40px;
|
||||||
|
transition: box-shadow 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-form:focus-within {
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-icon {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
color: #666;
|
||||||
|
margin-right: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-input {
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
flex: 1;
|
||||||
|
height: 100%;
|
||||||
|
font-size: 1rem;
|
||||||
|
color: #333;
|
||||||
|
outline: none;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0;
|
||||||
|
/* Remove native search cancel button in WebKit */
|
||||||
|
-webkit-appearance: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Remove 'x' from search input in Chrome/Safari */
|
||||||
|
.search-input::-webkit-search-cancel-button {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-clear-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
padding: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
color: #999;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-clear-btn:hover {
|
||||||
|
background: #f0f0f0;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Search Results Popover */
|
||||||
|
.search-results-popover {
|
||||||
|
position: absolute;
|
||||||
|
top: 100%;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
margin-top: 8px;
|
||||||
|
background: white;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||||
|
overflow: hidden;
|
||||||
|
max-height: 400px;
|
||||||
|
overflow-y: auto;
|
||||||
|
z-index: 3002;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-results-list {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-result-item {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center; /* Vertical center alignment */
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border: none;
|
||||||
|
background: white;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.1s;
|
||||||
|
border-bottom: 1px solid #f0f0f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-result-item:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-result-item:hover,
|
||||||
|
.search-result-item:focus {
|
||||||
|
background: #f5f5f5;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-icon {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
background: #f0f0f0;
|
||||||
|
border-radius: 50%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden; /* For text truncation if needed */
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-title {
|
||||||
|
font-weight: 500;
|
||||||
|
color: #333;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-desc {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: #777;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|||||||
@@ -17,8 +17,8 @@ export default class ApplicationComponent extends Component {
|
|||||||
@tracked isSettingsOpen = false;
|
@tracked isSettingsOpen = false;
|
||||||
|
|
||||||
get isSidebarOpen() {
|
get isSidebarOpen() {
|
||||||
// We consider the sidebar "open" if we are in search or place routes.
|
// We consider the sidebar "open" if we are in search or place routes.
|
||||||
// This helps the map know if it should shift the center or adjust view.
|
// This helps the map know if it should shift the center or adjust view.
|
||||||
return (
|
return (
|
||||||
this.router.currentRouteName === 'place' ||
|
this.router.currentRouteName === 'place' ||
|
||||||
this.router.currentRouteName === 'place.new' ||
|
this.router.currentRouteName === 'place.new' ||
|
||||||
@@ -48,12 +48,12 @@ export default class ApplicationComponent extends Component {
|
|||||||
if (this.isSettingsOpen) {
|
if (this.isSettingsOpen) {
|
||||||
this.closeSettings();
|
this.closeSettings();
|
||||||
} else if (this.router.currentRouteName === 'search') {
|
} else if (this.router.currentRouteName === 'search') {
|
||||||
this.router.transitionTo('index');
|
this.router.transitionTo('index');
|
||||||
} else if (this.router.currentRouteName === 'place') {
|
} else if (this.router.currentRouteName === 'place') {
|
||||||
// If in place route, decide if we want to go back to search or index
|
// If in place route, decide if we want to go back to search or index
|
||||||
// For now, let's go to index or maybe back to search if search params exist?
|
// For now, let's go to index or maybe back to search if search params exist?
|
||||||
// Simplest behavior: clear selection
|
// Simplest behavior: clear selection
|
||||||
this.router.transitionTo('index');
|
this.router.transitionTo('index');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -77,11 +77,11 @@ export default class PlaceTemplate extends Component {
|
|||||||
navigateBack(place) {
|
navigateBack(place) {
|
||||||
// The sidebar calls this with null when "Back" is clicked.
|
// The sidebar calls this with null when "Back" is clicked.
|
||||||
if (place === null) {
|
if (place === null) {
|
||||||
// If we have history, go back (preserves search state)
|
// If we came from search results, go back in history
|
||||||
if (window.history.length > 1) {
|
if (this.mapUi.returnToSearch) {
|
||||||
window.history.back();
|
window.history.back();
|
||||||
} else {
|
} else {
|
||||||
// Fallback if opened directly
|
// Otherwise just close the sidebar (return to map index)
|
||||||
this.router.transitionTo('index');
|
this.router.transitionTo('index');
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -5,10 +5,12 @@ import { action } from '@ember/object';
|
|||||||
|
|
||||||
export default class SearchTemplate extends Component {
|
export default class SearchTemplate extends Component {
|
||||||
@service router;
|
@service router;
|
||||||
|
@service mapUi;
|
||||||
|
|
||||||
@action
|
@action
|
||||||
selectPlace(place) {
|
selectPlace(place) {
|
||||||
if (place) {
|
if (place) {
|
||||||
|
this.mapUi.returnToSearch = true;
|
||||||
this.router.transitionTo('place', place);
|
this.router.transitionTo('place', place);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
32
app/utils/osm.js
Normal file
32
app/utils/osm.js
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
export function getLocalizedName(tags, defaultName = 'Untitled Place') {
|
||||||
|
if (!tags) return defaultName;
|
||||||
|
|
||||||
|
// 1. Get user's preferred languages
|
||||||
|
const languages = navigator.languages || [navigator.language || 'en'];
|
||||||
|
|
||||||
|
// 2. Try to find a match for each preferred language
|
||||||
|
for (const lang of languages) {
|
||||||
|
if (!lang) continue;
|
||||||
|
|
||||||
|
// Handle "en-US", "de-DE", etc. -> look for "name:en", "name:de"
|
||||||
|
const shortLang = lang.split('-')[0];
|
||||||
|
const tagKey = `name:${shortLang}`;
|
||||||
|
|
||||||
|
if (tags[tagKey]) {
|
||||||
|
return tags[tagKey];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Fallback to standard "name"
|
||||||
|
if (tags.name) {
|
||||||
|
return tags.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Fallback to "name:en" (common in international places without local name)
|
||||||
|
if (tags['name:en']) {
|
||||||
|
return tags['name:en'];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Final fallback
|
||||||
|
return defaultName;
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "marco",
|
"name": "marco",
|
||||||
"version": "1.11.0",
|
"version": "1.11.4",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "Unhosted maps app",
|
"description": "Unhosted maps app",
|
||||||
"repository": {
|
"repository": {
|
||||||
@@ -34,6 +34,7 @@
|
|||||||
"lint:js:fix": "eslint . --fix",
|
"lint:js:fix": "eslint . --fix",
|
||||||
"start": "vite",
|
"start": "vite",
|
||||||
"test": "vite build --mode development && testem ci --port 0",
|
"test": "vite build --mode development && testem ci --port 0",
|
||||||
|
"preversion": "pnpm test",
|
||||||
"version": "pnpm build && git add release/"
|
"version": "pnpm build && git add release/"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
1
release/assets/main-G8wPYi_P.css
Normal file
1
release/assets/main-G8wPYi_P.css
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -26,8 +26,8 @@
|
|||||||
<meta name="msapplication-TileColor" content="#F6E9A6">
|
<meta name="msapplication-TileColor" content="#F6E9A6">
|
||||||
<meta name="msapplication-TileImage" content="/icons/icon-144.png">
|
<meta name="msapplication-TileImage" content="/icons/icon-144.png">
|
||||||
|
|
||||||
<script type="module" crossorigin src="/assets/main-y8e9Z0x2.js"></script>
|
<script type="module" crossorigin src="/assets/main-ji2SNMnp.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/main-D53xPL_H.css">
|
<link rel="stylesheet" crossorigin href="/assets/main-G8wPYi_P.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
114
tests/acceptance/navigation-test.js
Normal file
114
tests/acceptance/navigation-test.js
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
import { module, test } from 'qunit';
|
||||||
|
import { visit, currentURL, click, settled } from '@ember/test-helpers';
|
||||||
|
import { setupApplicationTest } from 'marco/tests/helpers';
|
||||||
|
import Service from '@ember/service';
|
||||||
|
import sinon from 'sinon';
|
||||||
|
|
||||||
|
class MockOsmService extends Service {
|
||||||
|
async getNearbyPois() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
osmId: '123',
|
||||||
|
lat: 1,
|
||||||
|
lon: 1,
|
||||||
|
osmTags: { name: 'Test Place', amenity: 'cafe' },
|
||||||
|
osmType: 'node',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
async getPoiById() {
|
||||||
|
return {
|
||||||
|
osmId: '123',
|
||||||
|
lat: 1,
|
||||||
|
lon: 1,
|
||||||
|
osmTags: { name: 'Test Place', amenity: 'cafe' },
|
||||||
|
osmType: 'node',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
async fetchOsmObject(id, type) {
|
||||||
|
return {
|
||||||
|
osmId: id,
|
||||||
|
osmType: type,
|
||||||
|
lat: 1,
|
||||||
|
lon: 1,
|
||||||
|
osmTags: { name: 'Test Place', amenity: 'cafe' },
|
||||||
|
title: 'Test Place',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class MockStorageService extends Service {
|
||||||
|
savedPlaces = [];
|
||||||
|
findPlaceById() {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
loadPlacesInBounds() {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
get placesInView() {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
rs = {
|
||||||
|
on: () => {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module('Acceptance | navigation', function (hooks) {
|
||||||
|
setupApplicationTest(hooks);
|
||||||
|
|
||||||
|
hooks.beforeEach(function () {
|
||||||
|
this.owner.register('service:osm', MockOsmService);
|
||||||
|
this.owner.register('service:storage', MockStorageService);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('navigating from search results to place and back uses history', 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 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');
|
||||||
|
|
||||||
|
assert.true(backStub.calledOnce, 'window.history.back() was called');
|
||||||
|
} finally {
|
||||||
|
backStub.restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('closing the sidebar resets the returnToSearch flag', async function (assert) {
|
||||||
|
const mapUi = this.owner.lookup('service:map-ui');
|
||||||
|
|
||||||
|
await visit('/search?lat=1&lon=1');
|
||||||
|
await click('.place-item'); // Sets returnToSearch = true
|
||||||
|
|
||||||
|
assert.true(mapUi.returnToSearch, 'Flag is set upon entering place');
|
||||||
|
|
||||||
|
// Click the Close (X) button
|
||||||
|
await click('.close-btn');
|
||||||
|
|
||||||
|
assert.strictEqual(currentURL(), '/', 'Returned to index');
|
||||||
|
assert.false(mapUi.returnToSearch, 'Flag is reset after closing sidebar');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('navigating directly to place and back closes sidebar', async function (assert) {
|
||||||
|
const backStub = sinon.stub(window.history, 'back');
|
||||||
|
try {
|
||||||
|
await visit('/place/osm:node:123');
|
||||||
|
assert.ok(currentURL().includes('/place/'), 'Visited place directly');
|
||||||
|
|
||||||
|
await click('.back-btn');
|
||||||
|
|
||||||
|
assert.strictEqual(currentURL(), '/', 'Returned to index/map');
|
||||||
|
assert.true(backStub.notCalled, 'window.history.back() was NOT called');
|
||||||
|
} finally {
|
||||||
|
backStub.restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
147
tests/acceptance/search-test.js
Normal file
147
tests/acceptance/search-test.js
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
import { module, test } from 'qunit';
|
||||||
|
import { visit, currentURL } from '@ember/test-helpers';
|
||||||
|
import { setupApplicationTest } from 'marco/tests/helpers';
|
||||||
|
import Service from '@ember/service';
|
||||||
|
|
||||||
|
module('Acceptance | search', function (hooks) {
|
||||||
|
setupApplicationTest(hooks);
|
||||||
|
|
||||||
|
test('visiting /search with q parameter performs text search', async function (assert) {
|
||||||
|
// Mock Photon Service
|
||||||
|
class MockPhotonService extends Service {
|
||||||
|
async search(query) {
|
||||||
|
if (query === 'Berlin') {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
title: 'Berlin',
|
||||||
|
lat: 52.52,
|
||||||
|
lon: 13.405,
|
||||||
|
osmId: '123',
|
||||||
|
osmType: 'R',
|
||||||
|
description: 'City in Germany',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Berlin Alexanderplatz',
|
||||||
|
lat: 52.521,
|
||||||
|
lon: 13.41,
|
||||||
|
osmId: '456',
|
||||||
|
osmType: 'N',
|
||||||
|
description: 'Square in Berlin',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.owner.register('service:photon', MockPhotonService);
|
||||||
|
|
||||||
|
// Mock Storage Service (empty)
|
||||||
|
class MockStorageService extends Service {
|
||||||
|
savedPlaces = [];
|
||||||
|
findPlaceById() {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
rs = {
|
||||||
|
on: () => {},
|
||||||
|
};
|
||||||
|
// Add placesInView since map component accesses it
|
||||||
|
placesInView = [];
|
||||||
|
loadPlacesInBounds() {
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.owner.register('service:storage', MockStorageService);
|
||||||
|
|
||||||
|
await visit('/search?q=Berlin');
|
||||||
|
|
||||||
|
assert.strictEqual(currentURL(), '/search?q=Berlin');
|
||||||
|
assert.dom('.places-list li').exists({ count: 2 });
|
||||||
|
assert.dom('.places-list li:first-child .place-name').hasText('Berlin');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('visiting /search with lat/lon performs nearby search', async function (assert) {
|
||||||
|
// Mock Osm Service
|
||||||
|
class MockOsmService extends Service {
|
||||||
|
async getNearbyPois() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
title: 'Nearby Cafe',
|
||||||
|
lat: 52.521,
|
||||||
|
lon: 13.406,
|
||||||
|
osmId: '789',
|
||||||
|
osmType: 'N',
|
||||||
|
_distance: 100, // Pre-calculated or ignored if mocked
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.owner.register('service:osm', MockOsmService);
|
||||||
|
|
||||||
|
// Mock Storage Service (empty)
|
||||||
|
class MockStorageService extends Service {
|
||||||
|
savedPlaces = [];
|
||||||
|
findPlaceById() {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
rs = {
|
||||||
|
on: () => {},
|
||||||
|
};
|
||||||
|
// Add placesInView since map component accesses it
|
||||||
|
placesInView = [];
|
||||||
|
loadPlacesInBounds() {
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.owner.register('service:storage', MockStorageService);
|
||||||
|
|
||||||
|
await visit('/search?lat=52.52&lon=13.405');
|
||||||
|
|
||||||
|
assert.strictEqual(currentURL(), '/search?lat=52.52&lon=13.405');
|
||||||
|
assert.dom('.places-list li').exists({ count: 1 });
|
||||||
|
assert.dom('.places-list li .place-name').hasText('Nearby Cafe');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('local bookmarks are merged into search results', async function (assert) {
|
||||||
|
// Mock Photon Service
|
||||||
|
class MockPhotonService extends Service {
|
||||||
|
async search() {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.owner.register('service:photon', MockPhotonService);
|
||||||
|
|
||||||
|
// Mock Storage Service with a bookmark
|
||||||
|
class MockStorageService extends Service {
|
||||||
|
savedPlaces = [
|
||||||
|
{
|
||||||
|
title: 'My Secret Base',
|
||||||
|
lat: 50.0,
|
||||||
|
lon: 10.0,
|
||||||
|
osmId: '999',
|
||||||
|
osmType: 'N',
|
||||||
|
description: 'Top Secret',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
findPlaceById(id) {
|
||||||
|
if (id === '999') return this.savedPlaces[0];
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
rs = {
|
||||||
|
on: () => {},
|
||||||
|
};
|
||||||
|
placesInView = [];
|
||||||
|
loadPlacesInBounds() {
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.owner.register('service:storage', MockStorageService);
|
||||||
|
|
||||||
|
await visit('/search?q=Secret');
|
||||||
|
|
||||||
|
assert.strictEqual(currentURL(), '/search?q=Secret');
|
||||||
|
assert.dom('.places-list li').exists({ count: 1 });
|
||||||
|
assert.dom('.places-list li .place-name').hasText('My Secret Base');
|
||||||
|
});
|
||||||
|
});
|
||||||
19
tests/integration/components/app-header-test.gjs
Normal file
19
tests/integration/components/app-header-test.gjs
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { module, test } from 'qunit';
|
||||||
|
import { setupRenderingTest } from 'marco/tests/helpers';
|
||||||
|
import { render } from '@ember/test-helpers';
|
||||||
|
import AppHeader from 'marco/components/app-header';
|
||||||
|
|
||||||
|
module('Integration | Component | app-header', function (hooks) {
|
||||||
|
setupRenderingTest(hooks);
|
||||||
|
|
||||||
|
test('it renders the search box', async function (assert) {
|
||||||
|
this.noop = () => {};
|
||||||
|
await render(
|
||||||
|
<template><AppHeader @onToggleMenu={{this.noop}} /></template>
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.dom('header.app-header').exists();
|
||||||
|
assert.dom('.search-box').exists('Search box is present in the header');
|
||||||
|
assert.dom('.menu-btn').exists('Menu button is present');
|
||||||
|
});
|
||||||
|
});
|
||||||
37
tests/integration/components/place-details-test.gjs
Normal file
37
tests/integration/components/place-details-test.gjs
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { module, test } from 'qunit';
|
||||||
|
import { setupRenderingTest } from 'marco/tests/helpers';
|
||||||
|
import { render } from '@ember/test-helpers';
|
||||||
|
import PlaceDetails from 'marco/components/place-details';
|
||||||
|
|
||||||
|
module('Integration | Component | place-details', function (hooks) {
|
||||||
|
setupRenderingTest(hooks);
|
||||||
|
|
||||||
|
test('it formats coordinates correctly', async function (assert) {
|
||||||
|
const place = {
|
||||||
|
title: 'Test Place',
|
||||||
|
lat: 52.520006789,
|
||||||
|
lon: 13.404954123,
|
||||||
|
description: 'A place for testing.',
|
||||||
|
};
|
||||||
|
|
||||||
|
await render(<template><PlaceDetails @place={{place}} /></template>);
|
||||||
|
|
||||||
|
assert.dom('.place-details').exists();
|
||||||
|
assert.dom('.place-details h3').hasText('Test Place');
|
||||||
|
|
||||||
|
// Check for the formatted coordinates link text
|
||||||
|
// "52.520007, 13.404954" (rounded)
|
||||||
|
assert.dom('.meta-info a[href*="geo:"]').hasText('52.520007, 13.404954');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('it handles missing coordinates gracefully', async function (assert) {
|
||||||
|
const place = {
|
||||||
|
title: 'Place without Coords',
|
||||||
|
};
|
||||||
|
|
||||||
|
await render(<template><PlaceDetails @place={{place}} /></template>);
|
||||||
|
|
||||||
|
assert.dom('.place-details h3').hasText('Place without Coords');
|
||||||
|
assert.dom('.meta-info a[href*="geo:"]').doesNotExist();
|
||||||
|
});
|
||||||
|
});
|
||||||
128
tests/integration/components/search-box-test.gjs
Normal file
128
tests/integration/components/search-box-test.gjs
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
import { module, test } from 'qunit';
|
||||||
|
import { setupRenderingTest } from 'marco/tests/helpers';
|
||||||
|
import { render, fillIn, click, waitFor } from '@ember/test-helpers';
|
||||||
|
import SearchBox from 'marco/components/search-box';
|
||||||
|
import Service from '@ember/service';
|
||||||
|
|
||||||
|
module('Integration | Component | search-box', function (hooks) {
|
||||||
|
setupRenderingTest(hooks);
|
||||||
|
|
||||||
|
test('it renders and handles search input', async function (assert) {
|
||||||
|
// Mock Photon Service
|
||||||
|
class MockPhotonService extends Service {
|
||||||
|
async search(query) {
|
||||||
|
if (query === 'test') {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
title: 'Test Place',
|
||||||
|
description: 'A test description',
|
||||||
|
lat: 10,
|
||||||
|
lon: 20,
|
||||||
|
osmId: '123',
|
||||||
|
osmType: 'node',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.owner.register('service:photon', MockPhotonService);
|
||||||
|
|
||||||
|
// Mock Router Service
|
||||||
|
class MockRouterService extends Service {
|
||||||
|
transitionTo(routeName, ...args) {
|
||||||
|
assert.step(`transitionTo: ${routeName} ${JSON.stringify(args)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.owner.register('service:router', MockRouterService);
|
||||||
|
|
||||||
|
await render(<template><SearchBox /></template>);
|
||||||
|
|
||||||
|
assert.dom('.search-input').exists();
|
||||||
|
assert.dom('.search-results-popover').doesNotExist();
|
||||||
|
|
||||||
|
// Type 'test'
|
||||||
|
await fillIn('.search-input', 'test');
|
||||||
|
|
||||||
|
// Wait for debounce and async search
|
||||||
|
await waitFor('.search-results-popover', { timeout: 2000 });
|
||||||
|
|
||||||
|
assert.dom('.search-result-item').exists({ count: 1 });
|
||||||
|
assert.dom('.result-title').hasText('Test Place');
|
||||||
|
assert.dom('.result-desc').hasText('A test description');
|
||||||
|
|
||||||
|
// Click result
|
||||||
|
await click('.search-result-item');
|
||||||
|
|
||||||
|
assert.verifySteps(['transitionTo: place ["osm:node:123"]']);
|
||||||
|
assert
|
||||||
|
.dom('.search-results-popover')
|
||||||
|
.doesNotExist('Popover closes after selection');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('it handles submit for full search', async function (assert) {
|
||||||
|
// Mock Photon Service
|
||||||
|
class MockPhotonService extends Service {
|
||||||
|
async search() {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.owner.register('service:photon', MockPhotonService);
|
||||||
|
|
||||||
|
// Mock MapUi Service
|
||||||
|
class MockMapUiService extends Service {
|
||||||
|
currentCenter = { lat: 52.52, lon: 13.405 };
|
||||||
|
}
|
||||||
|
this.owner.register('service:map-ui', MockMapUiService);
|
||||||
|
|
||||||
|
// Mock Router Service
|
||||||
|
class MockRouterService extends Service {
|
||||||
|
transitionTo(routeName, options) {
|
||||||
|
assert.step(
|
||||||
|
`transitionTo: ${routeName} ${JSON.stringify(options)}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.owner.register('service:router', MockRouterService);
|
||||||
|
|
||||||
|
await render(<template><SearchBox /></template>);
|
||||||
|
|
||||||
|
await fillIn('.search-input', 'berlin');
|
||||||
|
await click('.search-input'); // Focus
|
||||||
|
// Trigger submit event on the form
|
||||||
|
await this.element
|
||||||
|
.querySelector('form')
|
||||||
|
.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));
|
||||||
|
|
||||||
|
assert.verifySteps([
|
||||||
|
'transitionTo: search {"queryParams":{"q":"berlin","selected":null,"lat":"52.5200","lon":"13.4050"}}',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('it uses map center for biased search', async function (assert) {
|
||||||
|
// Mock MapUi Service
|
||||||
|
class MockMapUiService extends Service {
|
||||||
|
currentCenter = { lat: 52.52, lon: 13.405 };
|
||||||
|
}
|
||||||
|
this.owner.register('service:map-ui', MockMapUiService);
|
||||||
|
|
||||||
|
// Mock Photon Service
|
||||||
|
class MockPhotonService extends Service {
|
||||||
|
async search(query, lat, lon) {
|
||||||
|
assert.step(`search: ${query}, ${lat}, ${lon}`);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.owner.register('service:photon', MockPhotonService);
|
||||||
|
|
||||||
|
await render(<template><SearchBox /></template>);
|
||||||
|
|
||||||
|
await fillIn('.search-input', 'cafe');
|
||||||
|
|
||||||
|
// Wait for debounce (300ms) + execution
|
||||||
|
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
await delay(400);
|
||||||
|
|
||||||
|
assert.verifySteps(['search: cafe, 52.52, 13.405']);
|
||||||
|
});
|
||||||
|
});
|
||||||
113
tests/unit/services/osm-test.js
Normal file
113
tests/unit/services/osm-test.js
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
import { module, test } from 'qunit';
|
||||||
|
import { setupTest } from 'marco/tests/helpers';
|
||||||
|
|
||||||
|
module('Unit | Service | osm', function (hooks) {
|
||||||
|
setupTest(hooks);
|
||||||
|
|
||||||
|
test('it exists', function (assert) {
|
||||||
|
let service = this.owner.lookup('service:osm');
|
||||||
|
assert.ok(service);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('normalizeOsmApiData handles nodes correctly', function (assert) {
|
||||||
|
let service = this.owner.lookup('service:osm');
|
||||||
|
const elements = [
|
||||||
|
{
|
||||||
|
id: 123,
|
||||||
|
type: 'node',
|
||||||
|
lat: 52.5,
|
||||||
|
lon: 13.4,
|
||||||
|
tags: { name: 'Test Node' },
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = service.normalizeOsmApiData(elements, 123, 'node');
|
||||||
|
|
||||||
|
assert.strictEqual(result.title, 'Test Node');
|
||||||
|
assert.strictEqual(result.lat, 52.5);
|
||||||
|
assert.strictEqual(result.lon, 13.4);
|
||||||
|
assert.strictEqual(result.osmId, '123');
|
||||||
|
assert.strictEqual(result.osmType, 'node');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('normalizeOsmApiData calculates centroid for ways', function (assert) {
|
||||||
|
let service = this.owner.lookup('service:osm');
|
||||||
|
const elements = [
|
||||||
|
{
|
||||||
|
id: 456,
|
||||||
|
type: 'way',
|
||||||
|
nodes: [1, 2],
|
||||||
|
tags: { name: 'Test Way' },
|
||||||
|
},
|
||||||
|
{ id: 1, type: 'node', lat: 10, lon: 10 },
|
||||||
|
{ id: 2, type: 'node', lat: 20, lon: 20 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = service.normalizeOsmApiData(elements, 456, 'way');
|
||||||
|
|
||||||
|
assert.strictEqual(result.title, 'Test Way');
|
||||||
|
assert.strictEqual(result.lat, 15); // (10+20)/2
|
||||||
|
assert.strictEqual(result.lon, 15); // (10+20)/2
|
||||||
|
assert.strictEqual(result.osmId, '456');
|
||||||
|
assert.strictEqual(result.osmType, 'way');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('normalizeOsmApiData calculates centroid for relations with member nodes', function (assert) {
|
||||||
|
let service = this.owner.lookup('service:osm');
|
||||||
|
const elements = [
|
||||||
|
{
|
||||||
|
id: 789,
|
||||||
|
type: 'relation',
|
||||||
|
members: [
|
||||||
|
{ type: 'node', ref: 1, role: 'admin_centre' },
|
||||||
|
{ type: 'node', ref: 2, role: 'label' },
|
||||||
|
],
|
||||||
|
tags: { name: 'Test Relation' },
|
||||||
|
},
|
||||||
|
{ id: 1, type: 'node', lat: 10, lon: 10 },
|
||||||
|
{ id: 2, type: 'node', lat: 30, lon: 30 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = service.normalizeOsmApiData(elements, 789, 'relation');
|
||||||
|
|
||||||
|
assert.strictEqual(result.title, 'Test Relation');
|
||||||
|
assert.strictEqual(result.lat, 20); // (10+30)/2
|
||||||
|
assert.strictEqual(result.lon, 20); // (10+30)/2
|
||||||
|
assert.strictEqual(result.osmId, '789');
|
||||||
|
assert.strictEqual(result.osmType, 'relation');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('normalizeOsmApiData calculates centroid for relations with member ways', function (assert) {
|
||||||
|
let service = this.owner.lookup('service:osm');
|
||||||
|
/*
|
||||||
|
Relation 999
|
||||||
|
-> Way 888
|
||||||
|
-> Node 1 (10, 10)
|
||||||
|
-> Node 2 (20, 20)
|
||||||
|
*/
|
||||||
|
const elements = [
|
||||||
|
{
|
||||||
|
id: 999,
|
||||||
|
type: 'relation',
|
||||||
|
members: [{ type: 'way', ref: 888, role: 'outer' }],
|
||||||
|
tags: { name: 'Complex Relation' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 888,
|
||||||
|
type: 'way',
|
||||||
|
nodes: [1, 2],
|
||||||
|
},
|
||||||
|
{ id: 1, type: 'node', lat: 10, lon: 10 },
|
||||||
|
{ id: 2, type: 'node', lat: 20, lon: 20 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = service.normalizeOsmApiData(elements, 999, 'relation');
|
||||||
|
|
||||||
|
assert.strictEqual(result.title, 'Complex Relation');
|
||||||
|
// It averages all nodes found. In this case, Node 1 and Node 2.
|
||||||
|
assert.strictEqual(result.lat, 15); // (10+20)/2
|
||||||
|
assert.strictEqual(result.lon, 15); // (10+20)/2
|
||||||
|
assert.strictEqual(result.osmId, '999');
|
||||||
|
assert.strictEqual(result.osmType, 'relation');
|
||||||
|
});
|
||||||
|
});
|
||||||
137
tests/unit/services/photon-test.js
Normal file
137
tests/unit/services/photon-test.js
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
import { module, test } from 'qunit';
|
||||||
|
import { setupTest } from 'marco/tests/helpers';
|
||||||
|
|
||||||
|
module('Unit | Service | photon', function (hooks) {
|
||||||
|
setupTest(hooks);
|
||||||
|
|
||||||
|
test('it exists', function (assert) {
|
||||||
|
let service = this.owner.lookup('service:photon');
|
||||||
|
assert.ok(service);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('search truncates coordinates to 4 decimal places', async function (assert) {
|
||||||
|
let service = this.owner.lookup('service:photon');
|
||||||
|
const originalFetch = window.fetch;
|
||||||
|
|
||||||
|
let capturedUrl;
|
||||||
|
window.fetch = async (url) => {
|
||||||
|
capturedUrl = url;
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ features: [] }),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
await service.search('Test', 52.123456, 13.987654);
|
||||||
|
assert.ok(
|
||||||
|
capturedUrl.includes('lat=52.1235'),
|
||||||
|
'lat is rounded to 4 decimals'
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
capturedUrl.includes('lon=13.9877'),
|
||||||
|
'lon is rounded to 4 decimals'
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
window.fetch = originalFetch;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('search handles successful response', async function (assert) {
|
||||||
|
let service = this.owner.lookup('service:photon');
|
||||||
|
|
||||||
|
// Mock fetch
|
||||||
|
const originalFetch = window.fetch;
|
||||||
|
window.fetch = async () => {
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
features: [
|
||||||
|
{
|
||||||
|
properties: {
|
||||||
|
name: 'Test Place',
|
||||||
|
osm_id: 123,
|
||||||
|
osm_type: 'N',
|
||||||
|
city: 'Test City',
|
||||||
|
country: 'Test Country',
|
||||||
|
},
|
||||||
|
geometry: {
|
||||||
|
coordinates: [13.4, 52.5], // lon, lat
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const results = await service.search('Test', 52.5, 13.4);
|
||||||
|
assert.strictEqual(results.length, 1);
|
||||||
|
assert.strictEqual(results[0].title, 'Test Place');
|
||||||
|
assert.strictEqual(results[0].lat, 52.5);
|
||||||
|
assert.strictEqual(results[0].lon, 13.4);
|
||||||
|
assert.strictEqual(results[0].description, 'Test City, Test Country');
|
||||||
|
assert.strictEqual(results[0].osmType, 'node', 'Normalizes N to node');
|
||||||
|
} finally {
|
||||||
|
window.fetch = originalFetch;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('search handles empty response', async function (assert) {
|
||||||
|
let service = this.owner.lookup('service:photon');
|
||||||
|
|
||||||
|
// Mock fetch
|
||||||
|
const originalFetch = window.fetch;
|
||||||
|
window.fetch = async () => {
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ features: [] }),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const results = await service.search('Nonexistent', 52.5, 13.4);
|
||||||
|
assert.strictEqual(results.length, 0);
|
||||||
|
} finally {
|
||||||
|
window.fetch = originalFetch;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('normalizeFeature handles missing properties', function (assert) {
|
||||||
|
let service = this.owner.lookup('service:photon');
|
||||||
|
|
||||||
|
const feature = {
|
||||||
|
properties: {
|
||||||
|
street: 'Main St',
|
||||||
|
housenumber: '123',
|
||||||
|
city: 'Metropolis',
|
||||||
|
},
|
||||||
|
geometry: {
|
||||||
|
coordinates: [10, 20],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = service.normalizeFeature(feature);
|
||||||
|
assert.strictEqual(result.title, 'Main St 123, Metropolis'); // Fallback to address description
|
||||||
|
assert.strictEqual(result.lat, 20);
|
||||||
|
assert.strictEqual(result.lon, 10);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('normalizeFeature normalizes OSM types correctly', function (assert) {
|
||||||
|
let service = this.owner.lookup('service:photon');
|
||||||
|
|
||||||
|
const checkType = (input, expected) => {
|
||||||
|
const feature = {
|
||||||
|
properties: { osm_type: input, name: 'Test' },
|
||||||
|
geometry: { coordinates: [0, 0] },
|
||||||
|
};
|
||||||
|
const result = service.normalizeFeature(feature);
|
||||||
|
assert.strictEqual(result.osmType, expected, `${input} -> ${expected}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
checkType('N', 'node');
|
||||||
|
checkType('W', 'way');
|
||||||
|
checkType('R', 'relation');
|
||||||
|
checkType('unknown', 'unknown'); // Fallback
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user