Compare commits
31 Commits
v1.11.2
...
feature/10
| Author | SHA1 | Date | |
|---|---|---|---|
|
d827fe263b
|
|||
|
1926e2b20c
|
|||
|
df1f32d8bd
|
|||
|
aa058bd7a3
|
|||
|
361a826e4f
|
|||
|
ff01d54fdd
|
|||
|
f73677139d
|
|||
|
8135695bba
|
|||
|
8217e85836
|
|||
|
d9645d1a8c
|
|||
|
688e8eda8d
|
|||
|
323aab8256
|
|||
|
ecb3fe4b5a
|
|||
|
43b2700465
|
|||
|
00454c8fab
|
|||
|
bf12305600
|
|||
|
2734f08608
|
|||
|
2aa59f9384
|
|||
|
bcf8ca4255
|
|||
|
20f63065ad
|
|||
|
39a7ec3595
|
|||
| 32dfa3a30f | |||
|
64ccc694d3
|
|||
|
87e2380ef6
|
|||
| 66c31b19f1 | |||
|
55aecbd699
|
|||
|
ccaa56b78f
|
|||
|
d30375707a
|
|||
|
53300b92f5
|
|||
|
c37f794eea
|
|||
|
4bc92bb7cc
|
@@ -36,6 +36,9 @@ We are building **Marco**, a decentralized maps application using **Ember.js** (
|
|||||||
- **Persistence:** Saves and restores map center and zoom level using `localStorage` (key: `marco:map-view`).
|
- **Persistence:** Saves and restores map center and zoom level using `localStorage` (key: `marco:map-view`).
|
||||||
- **Controls:** Enabled standard OpenLayers Rotate control (re-north) and custom Locate control.
|
- **Controls:** Enabled standard OpenLayers Rotate control (re-north) and custom Locate control.
|
||||||
- **Pin Animation:** Selected pins are highlighted with a custom **Red Pin** overlay that drops in with an animation. The center dot is styled as a solid dark red circle (`#b31412`).
|
- **Pin Animation:** Selected pins are highlighted with a custom **Red Pin** overlay that drops in with an animation. The center dot is styled as a solid dark red circle (`#b31412`).
|
||||||
|
- **Smart Zoom:** Implemented `zoomToBbox` to automatically fit complex geometries (ways/relations) within the visible viewport.
|
||||||
|
- **Dynamic Padding:** Calculates padding based on active UI elements (Sidebar on Desktop, Bottom Sheet on Mobile) to ensure the geometry is perfectly centered in the _visible_ map area.
|
||||||
|
- **Data Processing:** `OsmService` now calculates bounding boxes for ways and relations by aggregating member node coordinates.
|
||||||
|
|
||||||
### 2. RemoteStorage Module (`@remotestorage/module-places`)
|
### 2. RemoteStorage Module (`@remotestorage/module-places`)
|
||||||
|
|
||||||
@@ -103,6 +106,16 @@ We are building **Marco**, a decentralized maps application using **Ember.js** (
|
|||||||
- Responsive crosshair sizing (48px desktop / 24px mobile).
|
- Responsive crosshair sizing (48px desktop / 24px mobile).
|
||||||
- **Persistence:** Form data (Title, Description) and Map coordinates are securely saved to RemoteStorage via `storage.storePlace`.
|
- **Persistence:** Form data (Title, Description) and Map coordinates are securely saved to RemoteStorage via `storage.storePlace`.
|
||||||
|
|
||||||
|
### 6. Search Functionality
|
||||||
|
|
||||||
|
- **Provider:** Integrated **Photon API** (by Komoot) via `app/services/photon.js` for high-quality, typo-tolerant OpenStreetMap search.
|
||||||
|
- **UI:** `SearchBoxComponent` implements a responsive search bar with instant autocomplete.
|
||||||
|
- **Debounced Input:** 300ms delay to prevent excessive API calls.
|
||||||
|
- **Location Bias:** Automatically biases search results towards the current map center to show relevant local places first.
|
||||||
|
- **Direct Navigation:** Selecting a result with a valid OSM ID navigates directly to the specific place details (`/place/osm:type:id`).
|
||||||
|
- **Resilience:** Implemented retry logic (exponential backoff/fixed delay) for network errors and rate limits (429).
|
||||||
|
- **Data Normalization:** Search results are normalized to match the internal POI schema, ensuring consistent rendering across Search and Map views.
|
||||||
|
|
||||||
## Current State
|
## Current State
|
||||||
|
|
||||||
- **Repo:** The app runs via `pnpm start`.
|
- **Repo:** The app runs via `pnpm start`.
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -23,20 +24,13 @@ export default class AppHeaderComponent extends Component {
|
|||||||
<template>
|
<template>
|
||||||
<header class="app-header">
|
<header class="app-header">
|
||||||
<div class="header-left">
|
<div class="header-left">
|
||||||
<button
|
<SearchBox @onToggleMenu={{@onToggleMenu}} />
|
||||||
class="icon-btn"
|
|
||||||
type="button"
|
|
||||||
aria-label="Menu"
|
|
||||||
{{on "click" @onToggleMenu}}
|
|
||||||
>
|
|
||||||
<Icon @name="menu" @size={{24}} @color="#333" />
|
|
||||||
</button>
|
|
||||||
</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}}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import navigation from 'feather-icons/dist/icons/navigation.svg?raw';
|
|||||||
import phone from 'feather-icons/dist/icons/phone.svg?raw';
|
import phone from 'feather-icons/dist/icons/phone.svg?raw';
|
||||||
import plus from 'feather-icons/dist/icons/plus.svg?raw';
|
import plus from 'feather-icons/dist/icons/plus.svg?raw';
|
||||||
import server from 'feather-icons/dist/icons/server.svg?raw';
|
import server from 'feather-icons/dist/icons/server.svg?raw';
|
||||||
|
import search from 'feather-icons/dist/icons/search.svg?raw';
|
||||||
import settings from 'feather-icons/dist/icons/settings.svg?raw';
|
import settings from 'feather-icons/dist/icons/settings.svg?raw';
|
||||||
import target from 'feather-icons/dist/icons/target.svg?raw';
|
import target from 'feather-icons/dist/icons/target.svg?raw';
|
||||||
import user from 'feather-icons/dist/icons/user.svg?raw';
|
import user from 'feather-icons/dist/icons/user.svg?raw';
|
||||||
@@ -40,6 +41,7 @@ const ICONS = {
|
|||||||
phone,
|
phone,
|
||||||
plus,
|
plus,
|
||||||
server,
|
server,
|
||||||
|
search,
|
||||||
settings,
|
settings,
|
||||||
target,
|
target,
|
||||||
user,
|
user,
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import LayerGroup from 'ol/layer/Group.js';
|
|||||||
import VectorLayer from 'ol/layer/Vector.js';
|
import VectorLayer from 'ol/layer/Vector.js';
|
||||||
import VectorSource from 'ol/source/Vector.js';
|
import VectorSource from 'ol/source/Vector.js';
|
||||||
import Feature from 'ol/Feature.js';
|
import Feature from 'ol/Feature.js';
|
||||||
|
import GeoJSON from 'ol/format/GeoJSON.js';
|
||||||
import Point from 'ol/geom/Point.js';
|
import Point from 'ol/geom/Point.js';
|
||||||
import Geolocation from 'ol/Geolocation.js';
|
import Geolocation from 'ol/Geolocation.js';
|
||||||
import { Style, Circle, Fill, Stroke } from 'ol/style.js';
|
import { Style, Circle, Fill, Stroke } from 'ol/style.js';
|
||||||
@@ -27,18 +28,36 @@ export default class MapComponent extends Component {
|
|||||||
|
|
||||||
mapInstance;
|
mapInstance;
|
||||||
bookmarkSource;
|
bookmarkSource;
|
||||||
|
selectedShapeSource;
|
||||||
searchOverlay;
|
searchOverlay;
|
||||||
searchOverlayElement;
|
searchOverlayElement;
|
||||||
selectedPinOverlay;
|
selectedPinOverlay;
|
||||||
selectedPinElement;
|
selectedPinElement;
|
||||||
crosshairElement;
|
crosshairElement;
|
||||||
crosshairOverlay;
|
crosshairOverlay;
|
||||||
|
ignoreNextMapClick = false;
|
||||||
|
|
||||||
setupMap = modifier((element) => {
|
setupMap = modifier((element) => {
|
||||||
if (this.mapInstance) return;
|
if (this.mapInstance) return;
|
||||||
|
|
||||||
const openfreemap = new LayerGroup();
|
const openfreemap = new LayerGroup();
|
||||||
|
|
||||||
|
// Create a vector source and layer for the selected shape (outline)
|
||||||
|
this.selectedShapeSource = new VectorSource();
|
||||||
|
const selectedShapeLayer = new VectorLayer({
|
||||||
|
source: this.selectedShapeSource,
|
||||||
|
style: new Style({
|
||||||
|
stroke: new Stroke({
|
||||||
|
color: '#3388ff',
|
||||||
|
width: 4,
|
||||||
|
}),
|
||||||
|
fill: new Fill({
|
||||||
|
color: 'rgba(51, 136, 255, 0.1)',
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
zIndex: 5, // Below bookmarks (10) but above tiles
|
||||||
|
});
|
||||||
|
|
||||||
// Create a vector source and layer for bookmarks
|
// Create a vector source and layer for bookmarks
|
||||||
this.bookmarkSource = new VectorSource();
|
this.bookmarkSource = new VectorSource();
|
||||||
const bookmarkLayer = new VectorLayer({
|
const bookmarkLayer = new VectorLayer({
|
||||||
@@ -68,6 +87,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 +102,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) {
|
||||||
@@ -96,10 +117,10 @@ export default class MapComponent extends Component {
|
|||||||
|
|
||||||
this.mapInstance = new Map({
|
this.mapInstance = new Map({
|
||||||
target: element,
|
target: element,
|
||||||
layers: [openfreemap, bookmarkLayer],
|
layers: [openfreemap, selectedShapeLayer, bookmarkLayer],
|
||||||
view: view,
|
view: view,
|
||||||
controls: defaultControls({
|
controls: defaultControls({
|
||||||
zoom: false,
|
zoom: true,
|
||||||
rotate: true,
|
rotate: true,
|
||||||
attribution: true,
|
attribution: true,
|
||||||
}),
|
}),
|
||||||
@@ -108,6 +129,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 +178,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';
|
||||||
@@ -166,6 +188,18 @@ export default class MapComponent extends Component {
|
|||||||
});
|
});
|
||||||
this.mapInstance.addOverlay(this.locationOverlay);
|
this.mapInstance.addOverlay(this.locationOverlay);
|
||||||
|
|
||||||
|
// Track search box focus state on pointer down to handle race conditions
|
||||||
|
// The blur event fires before click, so we need to capture state here
|
||||||
|
element.addEventListener(
|
||||||
|
'pointerdown',
|
||||||
|
() => {
|
||||||
|
if (this.mapUi.searchBoxHasFocus) {
|
||||||
|
this.ignoreNextMapClick = true;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
true
|
||||||
|
);
|
||||||
|
|
||||||
// Geolocation Setup
|
// Geolocation Setup
|
||||||
const geolocation = new Geolocation({
|
const geolocation = new Geolocation({
|
||||||
trackingOptions: {
|
trackingOptions: {
|
||||||
@@ -243,6 +277,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 +342,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 +367,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 +378,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,7 +406,11 @@ 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
|
||||||
|
.getInteractions()
|
||||||
|
.getArray()
|
||||||
|
.slice()
|
||||||
|
.forEach((interaction) => {
|
||||||
if (interaction instanceof DragPan) {
|
if (interaction instanceof DragPan) {
|
||||||
this.mapInstance.removeInteraction(interaction);
|
this.mapInstance.removeInteraction(interaction);
|
||||||
}
|
}
|
||||||
@@ -397,6 +444,11 @@ export default class MapComponent extends Component {
|
|||||||
|
|
||||||
if (!this.selectedPinOverlay || !this.selectedPinElement) return;
|
if (!this.selectedPinOverlay || !this.selectedPinElement) return;
|
||||||
|
|
||||||
|
// Clear any previous shape
|
||||||
|
if (this.selectedShapeSource) {
|
||||||
|
this.selectedShapeSource.clear();
|
||||||
|
}
|
||||||
|
|
||||||
if (selected && selected.lat && selected.lon) {
|
if (selected && selected.lat && selected.lon) {
|
||||||
const coords = fromLonLat([selected.lon, selected.lat]);
|
const coords = fromLonLat([selected.lon, selected.lat]);
|
||||||
this.selectedPinOverlay.setPosition(coords);
|
this.selectedPinOverlay.setPosition(coords);
|
||||||
@@ -407,7 +459,23 @@ export default class MapComponent extends Component {
|
|||||||
void this.selectedPinElement.offsetWidth;
|
void this.selectedPinElement.offsetWidth;
|
||||||
this.selectedPinElement.classList.add('active');
|
this.selectedPinElement.classList.add('active');
|
||||||
|
|
||||||
|
// Draw GeoJSON shape if available
|
||||||
|
if (selected.geojson && this.selectedShapeSource) {
|
||||||
|
try {
|
||||||
|
const feature = new GeoJSON().readFeature(selected.geojson, {
|
||||||
|
featureProjection: 'EPSG:3857',
|
||||||
|
});
|
||||||
|
this.selectedShapeSource.addFeature(feature);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Failed to render selected place shape:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selected.bbox) {
|
||||||
|
this.zoomToBbox(selected.bbox);
|
||||||
|
} else {
|
||||||
this.handlePinVisibility(coords);
|
this.handlePinVisibility(coords);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
this.selectedPinElement.classList.remove('active');
|
this.selectedPinElement.classList.remove('active');
|
||||||
// Hide it effectively by moving it away or just relying on display:none in CSS
|
// Hide it effectively by moving it away or just relying on display:none in CSS
|
||||||
@@ -415,6 +483,55 @@ export default class MapComponent extends Component {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
zoomToBbox(bbox) {
|
||||||
|
if (!this.mapInstance || !bbox) return;
|
||||||
|
|
||||||
|
const view = this.mapInstance.getView();
|
||||||
|
const size = this.mapInstance.getSize();
|
||||||
|
|
||||||
|
// Convert bbox to extent: [minx, miny, maxx, maxy]
|
||||||
|
const min = fromLonLat([bbox.minLon, bbox.minLat]);
|
||||||
|
const max = fromLonLat([bbox.maxLon, bbox.maxLat]);
|
||||||
|
const extent = [...min, ...max];
|
||||||
|
|
||||||
|
// Default padding for full screen: 15% on all sides (70% visible)
|
||||||
|
let padding = [
|
||||||
|
size[1] * 0.15, // Top
|
||||||
|
size[0] * 0.15, // Right
|
||||||
|
size[1] * 0.15, // Bottom
|
||||||
|
size[0] * 0.15, // Left
|
||||||
|
];
|
||||||
|
|
||||||
|
// Mobile: Bottom sheet covers 50% of the screen height
|
||||||
|
if (size[0] <= 768) {
|
||||||
|
// We want the geometry to be centered in the top 50% of the screen.
|
||||||
|
// Top padding: 15% of the VISIBLE height (size[1] * 0.5)
|
||||||
|
const visibleHeight = size[1] * 0.5;
|
||||||
|
const topPadding = visibleHeight * 0.15;
|
||||||
|
const bottomPadding = (size[1] * 0.5) + (visibleHeight * 0.15); // Sheet + padding
|
||||||
|
|
||||||
|
padding[0] = topPadding;
|
||||||
|
padding[2] = bottomPadding;
|
||||||
|
}
|
||||||
|
// Desktop: Sidebar covers left side (approx 400px)
|
||||||
|
else if (this.args.isSidebarOpen) {
|
||||||
|
const sidebarWidth = 400;
|
||||||
|
const visibleWidth = size[0] - sidebarWidth;
|
||||||
|
|
||||||
|
// Left padding: Sidebar + 15% of visible width
|
||||||
|
padding[3] = sidebarWidth + (visibleWidth * 0.15);
|
||||||
|
// Right padding: 15% of visible width
|
||||||
|
padding[1] = visibleWidth * 0.15;
|
||||||
|
}
|
||||||
|
|
||||||
|
view.fit(extent, {
|
||||||
|
padding: padding,
|
||||||
|
duration: 1000,
|
||||||
|
easing: (t) => t * (2 - t),
|
||||||
|
maxZoom: 19,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
handlePinVisibility(coords) {
|
handlePinVisibility(coords) {
|
||||||
if (!this.mapInstance) return;
|
if (!this.mapInstance) return;
|
||||||
|
|
||||||
@@ -633,18 +750,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));
|
||||||
@@ -684,6 +812,11 @@ export default class MapComponent extends Component {
|
|||||||
};
|
};
|
||||||
|
|
||||||
handleMapClick = async (event) => {
|
handleMapClick = async (event) => {
|
||||||
|
if (this.ignoreNextMapClick) {
|
||||||
|
this.ignoreNextMapClick = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Check if user clicked on a rendered feature (POI or Bookmark) FIRST
|
// Check if user clicked on a rendered feature (POI or Bookmark) FIRST
|
||||||
const features = this.mapInstance.getFeaturesAtPixel(event.pixel, {
|
const features = this.mapInstance.getFeaturesAtPixel(event.pixel, {
|
||||||
hitTolerance: 10,
|
hitTolerance: 10,
|
||||||
@@ -733,6 +866,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;
|
||||||
|
|
||||||
@@ -762,10 +902,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, getPlaceType } 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
|
||||||
@@ -51,39 +47,50 @@ export default class PlaceDetails extends Component {
|
|||||||
}
|
}
|
||||||
|
|
||||||
get type() {
|
get type() {
|
||||||
const rawType =
|
return getPlaceType(this.tags);
|
||||||
this.tags.amenity ||
|
|
||||||
this.tags.shop ||
|
|
||||||
this.tags.tourism ||
|
|
||||||
this.tags.leisure ||
|
|
||||||
this.tags.historic ||
|
|
||||||
'Point of Interest';
|
|
||||||
|
|
||||||
return humanizeOsmTag(rawType);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
get address() {
|
get address() {
|
||||||
const t = this.tags;
|
const t = this.tags;
|
||||||
const parts = [];
|
const parts = [];
|
||||||
|
|
||||||
|
// Helper to get value from multiple keys
|
||||||
|
const get = (...keys) => {
|
||||||
|
for (const k of keys) {
|
||||||
|
if (t[k]) return t[k];
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
// Street + Number
|
// Street + Number
|
||||||
if (t['addr:street']) {
|
let street = get('addr:street', 'street');
|
||||||
let street = t['addr:street'];
|
const number = get('addr:housenumber', 'housenumber');
|
||||||
if (t['addr:housenumber']) {
|
|
||||||
street += ` ${t['addr:housenumber']}`;
|
if (street) {
|
||||||
|
if (number) {
|
||||||
|
street = `${street} ${number}`;
|
||||||
}
|
}
|
||||||
parts.push(street);
|
parts.push(street);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Postcode + City
|
// Postcode + City
|
||||||
if (t['addr:city']) {
|
let city = get('addr:city', 'city');
|
||||||
let city = t['addr:city'];
|
const postcode = get('addr:postcode', 'postcode');
|
||||||
if (t['addr:postcode']) {
|
|
||||||
city = `${t['addr:postcode']} ${city}`;
|
if (city) {
|
||||||
|
if (postcode) {
|
||||||
|
city = `${postcode} ${city}`;
|
||||||
}
|
}
|
||||||
parts.push(city);
|
parts.push(city);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// State + Country (if not already covered)
|
||||||
|
const state = get('addr:state', 'state');
|
||||||
|
const country = get('addr:country', 'country');
|
||||||
|
|
||||||
|
if (state && state !== city) parts.push(state);
|
||||||
|
if (country) parts.push(country);
|
||||||
|
|
||||||
if (parts.length === 0) return null;
|
if (parts.length === 0) return null;
|
||||||
return parts.join(', ');
|
return parts.join(', ');
|
||||||
}
|
}
|
||||||
@@ -129,7 +136,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() {
|
||||||
@@ -145,6 +152,16 @@ export default class PlaceDetails extends Component {
|
|||||||
return `https://www.google.com/maps/search/?api=1&query=${this.name}&query=${this.place.lat},${this.place.lon}`;
|
return `https://www.google.com/maps/search/?api=1&query=${this.name}&query=${this.place.lat},${this.place.lon}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get showDescription() {
|
||||||
|
// If it's a Photon result, the description IS the address.
|
||||||
|
// Since we are showing the address in the meta section (bottom),
|
||||||
|
// we should hide the description to avoid duplication.
|
||||||
|
if (this.place.source === 'photon') return false;
|
||||||
|
|
||||||
|
// Otherwise (e.g. saved place with custom description), show it.
|
||||||
|
return !!this.place.description;
|
||||||
|
}
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="place-details">
|
<div class="place-details">
|
||||||
{{#if this.isEditing}}
|
{{#if this.isEditing}}
|
||||||
@@ -158,7 +175,7 @@ export default class PlaceDetails extends Component {
|
|||||||
<p class="place-type">
|
<p class="place-type">
|
||||||
{{this.type}}
|
{{this.type}}
|
||||||
</p>
|
</p>
|
||||||
{{#if this.place.description}}
|
{{#if this.showDescription}}
|
||||||
<p class="place-description">
|
<p class="place-description">
|
||||||
{{this.place.description}}
|
{{this.place.description}}
|
||||||
</p>
|
</p>
|
||||||
@@ -274,7 +291,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>
|
||||||
|
|||||||
@@ -4,9 +4,11 @@ import { action } from '@ember/object';
|
|||||||
import { on } from '@ember/modifier';
|
import { on } from '@ember/modifier';
|
||||||
import { fn } from '@ember/helper';
|
import { fn } from '@ember/helper';
|
||||||
import or from 'ember-truth-helpers/helpers/or';
|
import or from 'ember-truth-helpers/helpers/or';
|
||||||
|
import eq from 'ember-truth-helpers/helpers/eq';
|
||||||
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, getPlaceType } from '../utils/osm';
|
||||||
|
|
||||||
export default class PlacesSidebar extends Component {
|
export default class PlacesSidebar extends Component {
|
||||||
@service storage;
|
@service storage;
|
||||||
@@ -23,7 +25,9 @@ export default class PlacesSidebar extends Component {
|
|||||||
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 +89,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: [],
|
||||||
@@ -184,14 +187,19 @@ 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">
|
||||||
place.osmTags.amenity
|
{{#if (eq place.source "osm")}}
|
||||||
place.osmTags.shop
|
{{humanizeOsmTag place.type}}
|
||||||
place.osmTags.tourism
|
{{else if (eq place.source "photon")}}
|
||||||
place.osmTags.leisure
|
{{place.description}}
|
||||||
place.osmTags.historic
|
{{else}}
|
||||||
"Point of Interest"
|
{{#if place.osmTags}}
|
||||||
)}}</div>
|
{{humanizeOsmTag (getPlaceType place.osmTags)}}
|
||||||
|
{{else if place.description}}
|
||||||
|
{{place.description}}
|
||||||
|
{{/if}}
|
||||||
|
{{/if}}
|
||||||
|
</div>
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
{{/each}}
|
{{/each}}
|
||||||
|
|||||||
199
app/components/search-box.gjs
Normal file
199
app/components/search-box.gjs
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
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 { 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';
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.searchTask.perform();
|
||||||
|
}
|
||||||
|
|
||||||
|
searchTask = task({ restartable: true }, async () => {
|
||||||
|
await timeout(300);
|
||||||
|
|
||||||
|
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;
|
||||||
|
this.mapUi.setSearchBoxFocus(true);
|
||||||
|
if (this.query.length >= 2 && this.results.length === 0) {
|
||||||
|
this.searchTask.perform();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
handleBlur() {
|
||||||
|
// Delay hiding so clicks on results can register
|
||||||
|
setTimeout(() => {
|
||||||
|
this.isFocused = false;
|
||||||
|
this.mapUi.setSearchBoxFocus(false);
|
||||||
|
}, 300);
|
||||||
|
}
|
||||||
|
|
||||||
|
@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}}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="menu-btn-integrated"
|
||||||
|
aria-label="Menu"
|
||||||
|
{{on "click" @onToggleMenu}}
|
||||||
|
>
|
||||||
|
<Icon @name="menu" @size={{20}} @color="#5f6368" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<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"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<button type="submit" class="search-submit-btn" aria-label="Search">
|
||||||
|
<Icon @name="search" @size={{20}} @color="#5f6368" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{{#if this.query}}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="search-clear-btn"
|
||||||
|
{{on "click" this.clear}}
|
||||||
|
aria-label="Clear"
|
||||||
|
>
|
||||||
|
<Icon @name="x" @size={{20}} @color="#5f6368" />
|
||||||
|
</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 (eq result.source "osm")}}
|
||||||
|
<span class="result-desc">{{humanizeOsmTag result.type}}</span>
|
||||||
|
{{else}}
|
||||||
|
{{#if result.description}}
|
||||||
|
<span class="result-desc">{{result.description}}</span>
|
||||||
|
{{/if}}
|
||||||
|
{{/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>
|
||||||
|
(as in
|
||||||
|
<a
|
||||||
href="https://en.wikipedia.org/wiki/Marco_Polo"
|
href="https://en.wikipedia.org/wiki/Marco_Polo"
|
||||||
target="_blank" rel="noopener">Marco Polo</a>) is an unhosted maps application
|
target="_blank"
|
||||||
that respects your privacy and choices.
|
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);
|
||||||
@@ -44,7 +48,28 @@ export default class PlaceRoute extends Route {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
afterModel(model) {
|
async afterModel(model) {
|
||||||
|
// If the model comes from a search result (e.g. Photon), it might lack detailed geometry.
|
||||||
|
// We want to ensure we have the full OSM object (with polygon/linestring) for display.
|
||||||
|
if (
|
||||||
|
model &&
|
||||||
|
model.osmId &&
|
||||||
|
model.osmType &&
|
||||||
|
model.osmType !== 'node' &&
|
||||||
|
!model.geojson
|
||||||
|
) {
|
||||||
|
// Only fetch if it's NOT a node (nodes don't have interesting geometry anyway, just a point)
|
||||||
|
// Although fetching nodes again ensures we have the latest tags too.
|
||||||
|
console.debug('Model missing geometry, fetching full OSM details...');
|
||||||
|
const fullDetails = await this.loadOsmPlace(model.osmId, model.osmType);
|
||||||
|
|
||||||
|
if (fullDetails) {
|
||||||
|
// Update the model in-place with the fuller details
|
||||||
|
Object.assign(model, fullDetails);
|
||||||
|
console.debug('Enriched model with full OSM details', model);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Notify the Map UI to show the pin
|
// Notify the Map UI to show the pin
|
||||||
if (model) {
|
if (model) {
|
||||||
this.mapUi.selectPlace(model);
|
this.mapUi.selectPlace(model);
|
||||||
@@ -62,7 +87,8 @@ export default class PlaceRoute extends Route {
|
|||||||
|
|
||||||
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,20 +14,46 @@ 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
|
|
||||||
let pois = await this.osm.getNearbyPois(lat, lon, searchRadius);
|
|
||||||
|
|
||||||
// Get cached/saved places in search radius
|
// Get cached/saved places in search radius
|
||||||
const localMatches = this.storage.savedPlaces.filter((p) => {
|
const localMatches = this.storage.savedPlaces.filter((p) => {
|
||||||
@@ -34,8 +61,7 @@ export default class SearchRoute extends Route {
|
|||||||
return dist <= searchRadius;
|
return dist <= searchRadius;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Add local matches to the list if they aren't already there
|
// Merge local matches
|
||||||
// We use osmId to deduplicate if possible
|
|
||||||
localMatches.forEach((local) => {
|
localMatches.forEach((local) => {
|
||||||
const exists = pois.find(
|
const exists = pois.find(
|
||||||
(poi) =>
|
(poi) =>
|
||||||
@@ -57,6 +83,7 @@ export default class SearchRoute extends Route {
|
|||||||
};
|
};
|
||||||
})
|
})
|
||||||
.sort((a, b) => a._distance - b._distance);
|
.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.
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ export default class MapUiService extends Service {
|
|||||||
@tracked isCreating = false;
|
@tracked isCreating = false;
|
||||||
@tracked creationCoordinates = null;
|
@tracked creationCoordinates = null;
|
||||||
@tracked returnToSearch = false;
|
@tracked returnToSearch = false;
|
||||||
|
@tracked currentCenter = null;
|
||||||
|
@tracked searchBoxHasFocus = false;
|
||||||
|
|
||||||
selectPlace(place) {
|
selectPlace(place) {
|
||||||
this.selectedPlace = place;
|
this.selectedPlace = place;
|
||||||
@@ -38,4 +40,12 @@ export default class MapUiService extends Service {
|
|||||||
updateCreationCoordinates(lat, lon) {
|
updateCreationCoordinates(lat, lon) {
|
||||||
this.creationCoordinates = { lat, lon };
|
this.creationCoordinates = { lat, lon };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setSearchBoxFocus(isFocused) {
|
||||||
|
this.searchBoxHasFocus = isFocused;
|
||||||
|
}
|
||||||
|
|
||||||
|
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, getPlaceType } from '../utils/osm';
|
||||||
|
|
||||||
export default class OsmService extends Service {
|
export default class OsmService extends Service {
|
||||||
@service settings;
|
@service settings;
|
||||||
@@ -23,14 +24,31 @@ export default class OsmService extends Service {
|
|||||||
this.controller = new AbortController();
|
this.controller = new AbortController();
|
||||||
const signal = this.controller.signal;
|
const signal = this.controller.signal;
|
||||||
|
|
||||||
|
const typeKeys = [
|
||||||
|
'amenity',
|
||||||
|
'shop',
|
||||||
|
'tourism',
|
||||||
|
'historic',
|
||||||
|
'leisure',
|
||||||
|
'office',
|
||||||
|
'craft',
|
||||||
|
'building',
|
||||||
|
'landuse',
|
||||||
|
'public_transport',
|
||||||
|
'highway',
|
||||||
|
'aeroway',
|
||||||
|
];
|
||||||
|
const typeKeysQuery = [`~"^(${typeKeys.join('|')})$"~".*"`];
|
||||||
|
|
||||||
const query = `
|
const query = `
|
||||||
[out:json][timeout:25];
|
[out:json][timeout:25];
|
||||||
(
|
(
|
||||||
nw["amenity"](around:${radius},${lat},${lon});
|
node(around:${radius},${lat},${lon})
|
||||||
nw["shop"](around:${radius},${lat},${lon});
|
[${typeKeysQuery}][~"^name"~"."];
|
||||||
nw["tourism"](around:${radius},${lat},${lon});
|
way(around:${radius},${lat},${lon})
|
||||||
nw["leisure"](around:${radius},${lat},${lon});
|
[${typeKeysQuery}][~"^name"~"."];
|
||||||
nw["historic"](around:${radius},${lat},${lon});
|
relation(around:${radius},${lat},${lon})
|
||||||
|
[${typeKeysQuery}][~"^name"~"."];
|
||||||
);
|
);
|
||||||
out center;
|
out center;
|
||||||
`.trim();
|
`.trim();
|
||||||
@@ -60,15 +78,20 @@ out center;
|
|||||||
}
|
}
|
||||||
|
|
||||||
normalizePoi(poi) {
|
normalizePoi(poi) {
|
||||||
|
const tags = poi.tags || {};
|
||||||
|
const type = getPlaceType(tags) || 'Point of Interest';
|
||||||
|
|
||||||
return {
|
return {
|
||||||
title: poi.tags?.name || poi.tags?.['name:en'] || 'Untitled Place',
|
title: getLocalizedName(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: tags.website,
|
||||||
osmId: String(poi.id),
|
osmId: String(poi.id),
|
||||||
osmType: poi.type,
|
osmType: poi.type,
|
||||||
osmTags: poi.tags || {},
|
osmTags: tags,
|
||||||
description: poi.tags?.description,
|
description: tags.description,
|
||||||
|
source: 'osm',
|
||||||
|
type: type,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -123,4 +146,211 @@ 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;
|
||||||
|
|
||||||
|
let mainElement = elements.find(
|
||||||
|
(el) => String(el.id) === String(targetId) && el.type === targetType
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!mainElement) return null;
|
||||||
|
|
||||||
|
// Use a separate variable for the element we want to display (tags, id, specific coords)
|
||||||
|
// vs the element we use for geometry calculation (bbox).
|
||||||
|
let displayElement = mainElement;
|
||||||
|
|
||||||
|
// If it's a boundary relation, try to find the label or admin_centre node
|
||||||
|
// and use that as the display element (better coordinates and tags).
|
||||||
|
if (targetType === 'relation' && mainElement.members) {
|
||||||
|
const labelMember = mainElement.members.find(
|
||||||
|
(m) => m.role === 'label' && m.type === 'node'
|
||||||
|
);
|
||||||
|
const adminCentreMember = mainElement.members.find(
|
||||||
|
(m) => m.role === 'admin_centre' && m.type === 'node'
|
||||||
|
);
|
||||||
|
|
||||||
|
const targetMember = labelMember || adminCentreMember;
|
||||||
|
|
||||||
|
if (targetMember) {
|
||||||
|
const targetNode = elements.find(
|
||||||
|
(el) =>
|
||||||
|
String(el.id) === String(targetMember.ref) && el.type === 'node'
|
||||||
|
);
|
||||||
|
if (targetNode) {
|
||||||
|
displayElement = targetNode;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let lat = displayElement.lat;
|
||||||
|
let lon = displayElement.lon;
|
||||||
|
let bbox = null;
|
||||||
|
let geojson = null;
|
||||||
|
|
||||||
|
// 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) {
|
||||||
|
// Only override lat/lon if we haven't switched to a specific display node
|
||||||
|
if (displayElement === mainElement) {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate BBox
|
||||||
|
const lats = coords.map((c) => c[1]);
|
||||||
|
const lons = coords.map((c) => c[0]);
|
||||||
|
bbox = {
|
||||||
|
minLat: Math.min(...lats),
|
||||||
|
maxLat: Math.max(...lats),
|
||||||
|
minLon: Math.min(...lons),
|
||||||
|
maxLon: Math.max(...lons),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Construct GeoJSON
|
||||||
|
if (coords.length > 1) {
|
||||||
|
const first = coords[0];
|
||||||
|
const last = coords[coords.length - 1];
|
||||||
|
const isClosed = first[0] === last[0] && first[1] === last[1];
|
||||||
|
|
||||||
|
if (isClosed) {
|
||||||
|
geojson = {
|
||||||
|
type: 'Polygon',
|
||||||
|
coordinates: [coords],
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
geojson = {
|
||||||
|
type: 'LineString',
|
||||||
|
coordinates: coords,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} 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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const segments = [];
|
||||||
|
|
||||||
|
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) {
|
||||||
|
const wayCoords = [];
|
||||||
|
way.nodes.forEach((nodeId) => {
|
||||||
|
const node = nodeMap.get(nodeId);
|
||||||
|
if (node) {
|
||||||
|
allNodes.push(node);
|
||||||
|
wayCoords.push([node.lon, node.lat]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (wayCoords.length > 1) {
|
||||||
|
segments.push(wayCoords);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (allNodes.length > 0) {
|
||||||
|
// Only override lat/lon if we haven't switched to a specific display node
|
||||||
|
if (displayElement === mainElement) {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate BBox
|
||||||
|
const lats = allNodes.map((n) => n.lat);
|
||||||
|
const lons = allNodes.map((n) => n.lon);
|
||||||
|
bbox = {
|
||||||
|
minLat: Math.min(...lats),
|
||||||
|
maxLat: Math.max(...lats),
|
||||||
|
minLon: Math.min(...lons),
|
||||||
|
maxLon: Math.max(...lons),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (segments.length > 0) {
|
||||||
|
geojson = {
|
||||||
|
type: 'MultiLineString',
|
||||||
|
coordinates: segments,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const tags = displayElement.tags || {};
|
||||||
|
const type = getPlaceType(tags) || 'Point of Interest';
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: getLocalizedName(tags),
|
||||||
|
lat,
|
||||||
|
lon,
|
||||||
|
bbox,
|
||||||
|
geojson,
|
||||||
|
url: tags.website,
|
||||||
|
osmId: String(displayElement.id),
|
||||||
|
osmType: displayElement.type,
|
||||||
|
osmTags: tags,
|
||||||
|
description: tags.description,
|
||||||
|
source: 'osm',
|
||||||
|
type: type,
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
119
app/services/photon.js
Normal file
119
app/services/photon.js
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
import Service from '@ember/service';
|
||||||
|
import { getPlaceType } from '../utils/osm';
|
||||||
|
import { humanizeOsmTag } from '../utils/format-text';
|
||||||
|
|
||||||
|
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',
|
||||||
|
};
|
||||||
|
|
||||||
|
const osmTags = { ...props };
|
||||||
|
// Photon often returns osm_key and osm_value for the main tag
|
||||||
|
if (props.osm_key && props.osm_value) {
|
||||||
|
osmTags[props.osm_key] = props.osm_value;
|
||||||
|
}
|
||||||
|
|
||||||
|
const type = getPlaceType(osmTags) || humanizeOsmTag(props.osm_value);
|
||||||
|
|
||||||
|
return {
|
||||||
|
title,
|
||||||
|
lat,
|
||||||
|
lon,
|
||||||
|
osmId: props.osm_id,
|
||||||
|
osmType: osmTypeMap[props.osm_type] || props.osm_type, // 'node', 'way', 'relation'
|
||||||
|
osmTags,
|
||||||
|
description: props.name ? description : addressParts.slice(1).join(', '),
|
||||||
|
source: 'photon',
|
||||||
|
type: type,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,16 +2,26 @@ import Service from '@ember/service';
|
|||||||
import { tracked } from '@glimmer/tracking';
|
import { tracked } from '@glimmer/tracking';
|
||||||
|
|
||||||
export default class SettingsService extends Service {
|
export default class SettingsService extends Service {
|
||||||
@tracked overpassApi = 'https://overpass.bke.ro/api/interpreter';
|
@tracked overpassApi = 'https://overpass-api.de/api/interpreter';
|
||||||
@tracked mapKinetic = true;
|
@tracked mapKinetic = true;
|
||||||
|
|
||||||
overpassApis = [
|
overpassApis = [
|
||||||
{ name: 'bke.ro', url: 'https://overpass.bke.ro/api/interpreter' },
|
|
||||||
{ name: 'overpass-api.de', url: 'https://overpass-api.de/api/interpreter' },
|
|
||||||
{
|
{
|
||||||
name: 'private.coffee',
|
name: 'overpass-api.de (DE)',
|
||||||
|
url: 'https://overpass-api.de/api/interpreter'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'private.coffee (AT)',
|
||||||
url: 'https://overpass.private.coffee/api/interpreter',
|
url: 'https://overpass.private.coffee/api/interpreter',
|
||||||
},
|
},
|
||||||
|
// {
|
||||||
|
// name: 'overpass.openstreetmap.us (US)',
|
||||||
|
// url: 'https://overpass.openstreetmap.us/api/interpreter'
|
||||||
|
// },
|
||||||
|
// {
|
||||||
|
// name: 'bke.ro (US)',
|
||||||
|
// url: 'https://overpass.bke.ro/api/interpreter'
|
||||||
|
// },
|
||||||
];
|
];
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
|
|||||||
@@ -5,11 +5,16 @@ 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 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-family: 'Noto Serif', sans-serif;
|
font-family: 'Noto Sans', sans-serif;
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
color: #333;
|
color: #333;
|
||||||
}
|
}
|
||||||
@@ -56,7 +61,7 @@ body {
|
|||||||
left: 0;
|
left: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
height: 60px;
|
height: 60px;
|
||||||
padding: 0 1rem;
|
padding: 0.5rem 1rem;
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -64,26 +69,27 @@ body {
|
|||||||
pointer-events: none; /* Let clicks pass through to map where transparent */
|
pointer-events: none; /* Let clicks pass through to map where transparent */
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (width <= 768px) {
|
||||||
|
.app-header {
|
||||||
|
padding: 0 0.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.header-left,
|
.header-left,
|
||||||
.header-right {
|
.header-right {
|
||||||
pointer-events: auto; /* Re-enable clicks for buttons */
|
pointer-events: auto; /* Re-enable clicks for buttons */
|
||||||
}
|
}
|
||||||
|
|
||||||
.icon-btn {
|
.header-left {
|
||||||
background: white;
|
|
||||||
border: none;
|
|
||||||
border-radius: 50%;
|
|
||||||
width: 40px;
|
|
||||||
height: 40px;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
}
|
||||||
box-shadow: 0 2px 5px rgb(0 0 0 / 20%);
|
|
||||||
cursor: pointer;
|
.btn-press {
|
||||||
transition: transform 0.1s;
|
transition: transform 0.1s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.icon-btn:active {
|
.btn-press:active {
|
||||||
transform: scale(0.95);
|
transform: scale(0.95);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -203,7 +209,6 @@ body {
|
|||||||
z-index: 3200; /* Higher than Places Sidebar (3100) */
|
z-index: 3200; /* Higher than Places Sidebar (3100) */
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Settings Pane Mobile Overrides */
|
|
||||||
@media (width <= 768px) {
|
@media (width <= 768px) {
|
||||||
.settings-pane.sidebar {
|
.settings-pane.sidebar {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -539,24 +544,54 @@ body {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Locate Control */
|
/* Map controls */
|
||||||
|
|
||||||
|
.ol-control.ol-attribution {
|
||||||
|
bottom: 1rem;
|
||||||
|
}
|
||||||
|
.ol-touch .ol-control.ol-attribution {
|
||||||
|
bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ol-control.ol-zoom {
|
||||||
|
bottom: 3rem;
|
||||||
|
}
|
||||||
|
.ol-touch .ol-control.ol-zoom {
|
||||||
|
bottom: 3.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
.ol-control.ol-locate {
|
.ol-control.ol-locate {
|
||||||
inset: auto 0.5em 2.5em auto;
|
bottom: 6.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ol-touch .ol-control.ol-locate {
|
.ol-touch .ol-control.ol-locate {
|
||||||
inset: auto 0.5em 3.5em auto;
|
bottom: 8.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Rotate Control */
|
.ol-control.ol-rotate {
|
||||||
.ol-rotate {
|
bottom: 9rem;
|
||||||
inset: auto 0.5em 5em auto;
|
}
|
||||||
|
.ol-touch .ol-control.ol-rotate {
|
||||||
|
bottom: 11.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ol-touch .ol-rotate {
|
.ol-control.ol-attribution,
|
||||||
inset: auto 0.5em 6em auto;
|
.ol-control.ol-zoom,
|
||||||
|
.ol-control.ol-locate,
|
||||||
|
.ol-control.ol-rotate {
|
||||||
|
top: auto;
|
||||||
|
left: auto;
|
||||||
|
right: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ol-touch .ol-control.ol-attribution,
|
||||||
|
.ol-touch .ol-control.ol-zoom,
|
||||||
|
.ol-touch .ol-control.ol-locate,
|
||||||
|
.ol-touch .ol-control.ol-rotate {
|
||||||
|
right: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Icons */
|
||||||
|
|
||||||
span.icon {
|
span.icon {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
}
|
}
|
||||||
@@ -724,3 +759,209 @@ 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: 400px;
|
||||||
|
margin-left: 0;
|
||||||
|
z-index: 3002; /* Higher than menu button to be safe */
|
||||||
|
}
|
||||||
|
|
||||||
|
.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.5rem;
|
||||||
|
height: 48px; /* Slightly taller for touch targets */
|
||||||
|
transition: box-shadow 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-form:focus-within {
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Integrated Menu Button */
|
||||||
|
.menu-btn-integrated {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
padding: 8px;
|
||||||
|
margin-right: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 50%;
|
||||||
|
color: #5f6368;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-btn-integrated:hover {
|
||||||
|
background: rgba(0, 0, 0, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Fallback Search Icon (Left) */
|
||||||
|
.search-icon {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: #5f6368;
|
||||||
|
margin-right: 0.5rem;
|
||||||
|
padding: 8px; /* Match button size */
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-input {
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
height: 100%;
|
||||||
|
font-size: 1rem;
|
||||||
|
color: #333;
|
||||||
|
outline: none;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0 4px;
|
||||||
|
/* 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Submit Button (Right) */
|
||||||
|
.search-submit-btn {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
padding: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: #5f6368;
|
||||||
|
border-radius: 50%;
|
||||||
|
margin-left: 4px;
|
||||||
|
border-left: 1px solid #ddd; /* Separator like Google Maps */
|
||||||
|
padding-left: 12px;
|
||||||
|
border-radius: 0; /* Reset for separator look */
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-submit-btn:hover {
|
||||||
|
/* No background on hover if we use separator style, or maybe just change icon color */
|
||||||
|
color: #1a73e8; /* Blue on hover */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* If we want the separator style, we need to adjust border-radius carefully or use a pseudo element */
|
||||||
|
/* Let's stick to a simple button for now, maybe without the separator if it looks cleaner */
|
||||||
|
.search-submit-btn {
|
||||||
|
border-left: none; /* Remove separator for cleaner look */
|
||||||
|
padding-left: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
.search-submit-btn:hover {
|
||||||
|
background: rgba(0, 0, 0, 0.05);
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-clear-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
padding: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: #5f6368;
|
||||||
|
border-radius: 50%;
|
||||||
|
margin-left: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-clear-btn:hover {
|
||||||
|
background: rgba(0, 0, 0, 0.05);
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 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;
|
||||||
|
}
|
||||||
|
|||||||
59
app/utils/osm.js
Normal file
59
app/utils/osm.js
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import { humanizeOsmTag } from './format-text';
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPlaceType(tags) {
|
||||||
|
if (!tags) return null;
|
||||||
|
|
||||||
|
const rawType =
|
||||||
|
tags.amenity ||
|
||||||
|
tags.shop ||
|
||||||
|
tags.tourism ||
|
||||||
|
tags.historic ||
|
||||||
|
tags.leisure ||
|
||||||
|
tags.office ||
|
||||||
|
tags.craft ||
|
||||||
|
tags.building ||
|
||||||
|
tags.landuse ||
|
||||||
|
tags.place ||
|
||||||
|
tags.natural ||
|
||||||
|
tags.public_transport ||
|
||||||
|
tags.highway ||
|
||||||
|
tags.aeroway ||
|
||||||
|
tags.waterway ||
|
||||||
|
tags.border_type ||
|
||||||
|
tags.admin_title;
|
||||||
|
|
||||||
|
return humanizeOsmTag(rawType);
|
||||||
|
}
|
||||||
@@ -2,6 +2,9 @@ import { dirname } from 'node:path';
|
|||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
import { setConfig } from '@warp-drive/core/build-config';
|
import { setConfig } from '@warp-drive/core/build-config';
|
||||||
import { buildMacros } from '@embroider/macros/babel';
|
import { buildMacros } from '@embroider/macros/babel';
|
||||||
|
import asyncArrowTaskTransform from 'ember-concurrency/async-arrow-task-transform';
|
||||||
|
|
||||||
|
console.log('Babel config loading, plugin:', typeof asyncArrowTaskTransform);
|
||||||
|
|
||||||
const macros = buildMacros({
|
const macros = buildMacros({
|
||||||
configure: (config) => {
|
configure: (config) => {
|
||||||
@@ -14,6 +17,7 @@ const macros = buildMacros({
|
|||||||
|
|
||||||
export default {
|
export default {
|
||||||
plugins: [
|
plugins: [
|
||||||
|
asyncArrowTaskTransform,
|
||||||
[
|
[
|
||||||
'babel-plugin-ember-template-compilation',
|
'babel-plugin-ember-template-compilation',
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "marco",
|
"name": "marco",
|
||||||
"version": "1.11.2",
|
"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": {
|
||||||
@@ -100,6 +101,7 @@
|
|||||||
"edition": "octane"
|
"edition": "octane"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"ember-concurrency": "^5.2.0",
|
||||||
"ember-lifeline": "^7.0.0"
|
"ember-lifeline": "^7.0.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
46
pnpm-lock.yaml
generated
46
pnpm-lock.yaml
generated
@@ -8,6 +8,9 @@ importers:
|
|||||||
|
|
||||||
.:
|
.:
|
||||||
dependencies:
|
dependencies:
|
||||||
|
ember-concurrency:
|
||||||
|
specifier: ^5.2.0
|
||||||
|
version: 5.2.0(@babel/core@7.28.6)
|
||||||
ember-lifeline:
|
ember-lifeline:
|
||||||
specifier: ^7.0.0
|
specifier: ^7.0.0
|
||||||
version: 7.0.0(@ember/test-helpers@5.4.1(@babel/core@7.28.6))
|
version: 7.0.0(@ember/test-helpers@5.4.1(@babel/core@7.28.6))
|
||||||
@@ -1436,66 +1439,79 @@ packages:
|
|||||||
resolution: {integrity: sha512-Rn3n+FUk2J5VWx+ywrG/HGPTD9jXNbicRtTM11e/uorplArnXZYsVifnPPqNNP5BsO3roI4n8332ukpY/zN7rQ==}
|
resolution: {integrity: sha512-Rn3n+FUk2J5VWx+ywrG/HGPTD9jXNbicRtTM11e/uorplArnXZYsVifnPPqNNP5BsO3roI4n8332ukpY/zN7rQ==}
|
||||||
cpu: [arm]
|
cpu: [arm]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
'@rollup/rollup-linux-arm-musleabihf@4.55.1':
|
'@rollup/rollup-linux-arm-musleabihf@4.55.1':
|
||||||
resolution: {integrity: sha512-grPNWydeKtc1aEdrJDWk4opD7nFtQbMmV7769hiAaYyUKCT1faPRm2av8CX1YJsZ4TLAZcg9gTR1KvEzoLjXkg==}
|
resolution: {integrity: sha512-grPNWydeKtc1aEdrJDWk4opD7nFtQbMmV7769hiAaYyUKCT1faPRm2av8CX1YJsZ4TLAZcg9gTR1KvEzoLjXkg==}
|
||||||
cpu: [arm]
|
cpu: [arm]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
'@rollup/rollup-linux-arm64-gnu@4.55.1':
|
'@rollup/rollup-linux-arm64-gnu@4.55.1':
|
||||||
resolution: {integrity: sha512-a59mwd1k6x8tXKcUxSyISiquLwB5pX+fJW9TkWU46lCqD/GRDe9uDN31jrMmVP3feI3mhAdvcCClhV8V5MhJFQ==}
|
resolution: {integrity: sha512-a59mwd1k6x8tXKcUxSyISiquLwB5pX+fJW9TkWU46lCqD/GRDe9uDN31jrMmVP3feI3mhAdvcCClhV8V5MhJFQ==}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
'@rollup/rollup-linux-arm64-musl@4.55.1':
|
'@rollup/rollup-linux-arm64-musl@4.55.1':
|
||||||
resolution: {integrity: sha512-puS1MEgWX5GsHSoiAsF0TYrpomdvkaXm0CofIMG5uVkP6IBV+ZO9xhC5YEN49nsgYo1DuuMquF9+7EDBVYu4uA==}
|
resolution: {integrity: sha512-puS1MEgWX5GsHSoiAsF0TYrpomdvkaXm0CofIMG5uVkP6IBV+ZO9xhC5YEN49nsgYo1DuuMquF9+7EDBVYu4uA==}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
'@rollup/rollup-linux-loong64-gnu@4.55.1':
|
'@rollup/rollup-linux-loong64-gnu@4.55.1':
|
||||||
resolution: {integrity: sha512-r3Wv40in+lTsULSb6nnoudVbARdOwb2u5fpeoOAZjFLznp6tDU8kd+GTHmJoqZ9lt6/Sys33KdIHUaQihFcu7g==}
|
resolution: {integrity: sha512-r3Wv40in+lTsULSb6nnoudVbARdOwb2u5fpeoOAZjFLznp6tDU8kd+GTHmJoqZ9lt6/Sys33KdIHUaQihFcu7g==}
|
||||||
cpu: [loong64]
|
cpu: [loong64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
'@rollup/rollup-linux-loong64-musl@4.55.1':
|
'@rollup/rollup-linux-loong64-musl@4.55.1':
|
||||||
resolution: {integrity: sha512-MR8c0+UxAlB22Fq4R+aQSPBayvYa3+9DrwG/i1TKQXFYEaoW3B5b/rkSRIypcZDdWjWnpcvxbNaAJDcSbJU3Lw==}
|
resolution: {integrity: sha512-MR8c0+UxAlB22Fq4R+aQSPBayvYa3+9DrwG/i1TKQXFYEaoW3B5b/rkSRIypcZDdWjWnpcvxbNaAJDcSbJU3Lw==}
|
||||||
cpu: [loong64]
|
cpu: [loong64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
'@rollup/rollup-linux-ppc64-gnu@4.55.1':
|
'@rollup/rollup-linux-ppc64-gnu@4.55.1':
|
||||||
resolution: {integrity: sha512-3KhoECe1BRlSYpMTeVrD4sh2Pw2xgt4jzNSZIIPLFEsnQn9gAnZagW9+VqDqAHgm1Xc77LzJOo2LdigS5qZ+gw==}
|
resolution: {integrity: sha512-3KhoECe1BRlSYpMTeVrD4sh2Pw2xgt4jzNSZIIPLFEsnQn9gAnZagW9+VqDqAHgm1Xc77LzJOo2LdigS5qZ+gw==}
|
||||||
cpu: [ppc64]
|
cpu: [ppc64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
'@rollup/rollup-linux-ppc64-musl@4.55.1':
|
'@rollup/rollup-linux-ppc64-musl@4.55.1':
|
||||||
resolution: {integrity: sha512-ziR1OuZx0vdYZZ30vueNZTg73alF59DicYrPViG0NEgDVN8/Jl87zkAPu4u6VjZST2llgEUjaiNl9JM6HH1Vdw==}
|
resolution: {integrity: sha512-ziR1OuZx0vdYZZ30vueNZTg73alF59DicYrPViG0NEgDVN8/Jl87zkAPu4u6VjZST2llgEUjaiNl9JM6HH1Vdw==}
|
||||||
cpu: [ppc64]
|
cpu: [ppc64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
'@rollup/rollup-linux-riscv64-gnu@4.55.1':
|
'@rollup/rollup-linux-riscv64-gnu@4.55.1':
|
||||||
resolution: {integrity: sha512-uW0Y12ih2XJRERZ4jAfKamTyIHVMPQnTZcQjme2HMVDAHY4amf5u414OqNYC+x+LzRdRcnIG1YodLrrtA8xsxw==}
|
resolution: {integrity: sha512-uW0Y12ih2XJRERZ4jAfKamTyIHVMPQnTZcQjme2HMVDAHY4amf5u414OqNYC+x+LzRdRcnIG1YodLrrtA8xsxw==}
|
||||||
cpu: [riscv64]
|
cpu: [riscv64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
'@rollup/rollup-linux-riscv64-musl@4.55.1':
|
'@rollup/rollup-linux-riscv64-musl@4.55.1':
|
||||||
resolution: {integrity: sha512-u9yZ0jUkOED1BFrqu3BwMQoixvGHGZ+JhJNkNKY/hyoEgOwlqKb62qu+7UjbPSHYjiVy8kKJHvXKv5coH4wDeg==}
|
resolution: {integrity: sha512-u9yZ0jUkOED1BFrqu3BwMQoixvGHGZ+JhJNkNKY/hyoEgOwlqKb62qu+7UjbPSHYjiVy8kKJHvXKv5coH4wDeg==}
|
||||||
cpu: [riscv64]
|
cpu: [riscv64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
'@rollup/rollup-linux-s390x-gnu@4.55.1':
|
'@rollup/rollup-linux-s390x-gnu@4.55.1':
|
||||||
resolution: {integrity: sha512-/0PenBCmqM4ZUd0190j7J0UsQ/1nsi735iPRakO8iPciE7BQ495Y6msPzaOmvx0/pn+eJVVlZrNrSh4WSYLxNg==}
|
resolution: {integrity: sha512-/0PenBCmqM4ZUd0190j7J0UsQ/1nsi735iPRakO8iPciE7BQ495Y6msPzaOmvx0/pn+eJVVlZrNrSh4WSYLxNg==}
|
||||||
cpu: [s390x]
|
cpu: [s390x]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
'@rollup/rollup-linux-x64-gnu@4.55.1':
|
'@rollup/rollup-linux-x64-gnu@4.55.1':
|
||||||
resolution: {integrity: sha512-a8G4wiQxQG2BAvo+gU6XrReRRqj+pLS2NGXKm8io19goR+K8lw269eTrPkSdDTALwMmJp4th2Uh0D8J9bEV1vg==}
|
resolution: {integrity: sha512-a8G4wiQxQG2BAvo+gU6XrReRRqj+pLS2NGXKm8io19goR+K8lw269eTrPkSdDTALwMmJp4th2Uh0D8J9bEV1vg==}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
'@rollup/rollup-linux-x64-musl@4.55.1':
|
'@rollup/rollup-linux-x64-musl@4.55.1':
|
||||||
resolution: {integrity: sha512-bD+zjpFrMpP/hqkfEcnjXWHMw5BIghGisOKPj+2NaNDuVT+8Ds4mPf3XcPHuat1tz89WRL+1wbcxKY3WSbiT7w==}
|
resolution: {integrity: sha512-bD+zjpFrMpP/hqkfEcnjXWHMw5BIghGisOKPj+2NaNDuVT+8Ds4mPf3XcPHuat1tz89WRL+1wbcxKY3WSbiT7w==}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
'@rollup/rollup-openbsd-x64@4.55.1':
|
'@rollup/rollup-openbsd-x64@4.55.1':
|
||||||
resolution: {integrity: sha512-eLXw0dOiqE4QmvikfQ6yjgkg/xDM+MdU9YJuP4ySTibXU0oAvnEWXt7UDJmD4UkYialMfOGFPJnIHSe/kdzPxg==}
|
resolution: {integrity: sha512-eLXw0dOiqE4QmvikfQ6yjgkg/xDM+MdU9YJuP4ySTibXU0oAvnEWXt7UDJmD4UkYialMfOGFPJnIHSe/kdzPxg==}
|
||||||
@@ -2519,6 +2535,9 @@ packages:
|
|||||||
decimal.js@10.6.0:
|
decimal.js@10.6.0:
|
||||||
resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==}
|
resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==}
|
||||||
|
|
||||||
|
decorator-transforms@1.2.1:
|
||||||
|
resolution: {integrity: sha512-UUtmyfdlHvYoX3VSG1w5rbvBQ2r5TX1JsE4hmKU9snleFymadA3VACjl6SRfi9YgBCSjBbfQvR1bs9PRW9yBKw==}
|
||||||
|
|
||||||
decorator-transforms@2.3.1:
|
decorator-transforms@2.3.1:
|
||||||
resolution: {integrity: sha512-PDOk74Zqqy0946Lx4ckXxbgG6uhPScOICtrxL/pXmfznxchqNee0TaJISClGJQe6FeT8ohGqsOgdjfahm4FwEw==}
|
resolution: {integrity: sha512-PDOk74Zqqy0946Lx4ckXxbgG6uhPScOICtrxL/pXmfznxchqNee0TaJISClGJQe6FeT8ohGqsOgdjfahm4FwEw==}
|
||||||
|
|
||||||
@@ -2669,6 +2688,15 @@ packages:
|
|||||||
engines: {node: '>= 20.19.0'}
|
engines: {node: '>= 20.19.0'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
ember-concurrency@5.2.0:
|
||||||
|
resolution: {integrity: sha512-NUptPzaxaF2XWqn3VQ5KqiLSRqPFIZhWXH3UkOMhiedmiolxGYjUV96maoHWdd5msxNgQBC0UkZ28m7pV7A0sQ==}
|
||||||
|
engines: {node: 16.* || >= 18}
|
||||||
|
peerDependencies:
|
||||||
|
'@glint/template': '>= 1.0.0'
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@glint/template':
|
||||||
|
optional: true
|
||||||
|
|
||||||
ember-eslint-parser@0.5.13:
|
ember-eslint-parser@0.5.13:
|
||||||
resolution: {integrity: sha512-b6ALDaxs9Bb4v0uagWud/5lECb78qpXHFv7M340dUHFW4Y0RuhlsfA4Rb+765X1+6KHp8G7TaAs0UgggWUqD3g==}
|
resolution: {integrity: sha512-b6ALDaxs9Bb4v0uagWud/5lECb78qpXHFv7M340dUHFW4Y0RuhlsfA4Rb+765X1+6KHp8G7TaAs0UgggWUqD3g==}
|
||||||
engines: {node: '>=16.0.0'}
|
engines: {node: '>=16.0.0'}
|
||||||
@@ -8110,6 +8138,13 @@ snapshots:
|
|||||||
|
|
||||||
decimal.js@10.6.0: {}
|
decimal.js@10.6.0: {}
|
||||||
|
|
||||||
|
decorator-transforms@1.2.1(@babel/core@7.28.6):
|
||||||
|
dependencies:
|
||||||
|
'@babel/plugin-syntax-decorators': 7.28.6(@babel/core@7.28.6)
|
||||||
|
babel-import-util: 2.1.1
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- '@babel/core'
|
||||||
|
|
||||||
decorator-transforms@2.3.1(@babel/core@7.28.6):
|
decorator-transforms@2.3.1(@babel/core@7.28.6):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@babel/plugin-syntax-decorators': 7.28.6(@babel/core@7.28.6)
|
'@babel/plugin-syntax-decorators': 7.28.6(@babel/core@7.28.6)
|
||||||
@@ -8462,6 +8497,17 @@ snapshots:
|
|||||||
- walrus
|
- walrus
|
||||||
- whiskers
|
- whiskers
|
||||||
|
|
||||||
|
ember-concurrency@5.2.0(@babel/core@7.28.6):
|
||||||
|
dependencies:
|
||||||
|
'@babel/helper-module-imports': 7.28.6
|
||||||
|
'@babel/helper-plugin-utils': 7.28.6
|
||||||
|
'@babel/types': 7.28.6
|
||||||
|
'@embroider/addon-shim': 1.10.2
|
||||||
|
decorator-transforms: 1.2.1(@babel/core@7.28.6)
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- '@babel/core'
|
||||||
|
- supports-color
|
||||||
|
|
||||||
ember-eslint-parser@0.5.13(@babel/core@7.28.6)(eslint@9.39.2)(typescript@5.9.3):
|
ember-eslint-parser@0.5.13(@babel/core@7.28.6)(eslint@9.39.2)(typescript@5.9.3):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@babel/core': 7.28.6
|
'@babel/core': 7.28.6
|
||||||
|
|||||||
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-CYFdUlXN.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>
|
||||||
|
|||||||
@@ -25,6 +25,16 @@ class MockOsmService extends Service {
|
|||||||
osmType: 'node',
|
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 {
|
class MockStorageService extends Service {
|
||||||
@@ -82,7 +92,6 @@ module('Acceptance | navigation', function (hooks) {
|
|||||||
|
|
||||||
// Click the Close (X) button
|
// Click the Close (X) button
|
||||||
await click('.close-btn');
|
await click('.close-btn');
|
||||||
await settled();
|
|
||||||
|
|
||||||
assert.strictEqual(currentURL(), '/', 'Returned to index');
|
assert.strictEqual(currentURL(), '/', 'Returned to index');
|
||||||
assert.false(mapUi.returnToSearch, 'Flag is reset after closing sidebar');
|
assert.false(mapUi.returnToSearch, 'Flag is reset after closing sidebar');
|
||||||
@@ -95,7 +104,6 @@ module('Acceptance | navigation', function (hooks) {
|
|||||||
assert.ok(currentURL().includes('/place/'), 'Visited place directly');
|
assert.ok(currentURL().includes('/place/'), 'Visited place directly');
|
||||||
|
|
||||||
await click('.back-btn');
|
await click('.back-btn');
|
||||||
await settled();
|
|
||||||
|
|
||||||
assert.strictEqual(currentURL(), '/', 'Returned to index/map');
|
assert.strictEqual(currentURL(), '/', 'Returned to index/map');
|
||||||
assert.true(backStub.notCalled, 'window.history.back() was NOT called');
|
assert.true(backStub.notCalled, 'window.history.back() was NOT called');
|
||||||
|
|||||||
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-integrated').exists('Menu button is integrated');
|
||||||
|
});
|
||||||
|
});
|
||||||
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
131
tests/integration/components/search-box-test.gjs
Normal file
131
tests/integration/components/search-box-test.gjs
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
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);
|
||||||
|
|
||||||
|
this.noop = () => {};
|
||||||
|
await render(<template><SearchBox @onToggleMenu={{this.noop}} /></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 };
|
||||||
|
setSearchBoxFocus() {}
|
||||||
|
}
|
||||||
|
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);
|
||||||
|
|
||||||
|
this.noop = () => {};
|
||||||
|
await render(<template><SearchBox @onToggleMenu={{this.noop}} /></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 };
|
||||||
|
setSearchBoxFocus() {}
|
||||||
|
}
|
||||||
|
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);
|
||||||
|
|
||||||
|
this.noop = () => {};
|
||||||
|
await render(<template><SearchBox @onToggleMenu={{this.noop}} /></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']);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { module, test } from 'qunit';
|
import { module, test } from 'qunit';
|
||||||
import { setupTest } from 'marco/tests/helpers';
|
import { setupTest } from 'marco/tests/helpers';
|
||||||
|
import Service from '@ember/service';
|
||||||
|
|
||||||
module('Unit | Route | place', function (hooks) {
|
module('Unit | Route | place', function (hooks) {
|
||||||
setupTest(hooks);
|
setupTest(hooks);
|
||||||
@@ -8,4 +9,120 @@ module('Unit | Route | place', function (hooks) {
|
|||||||
let route = this.owner.lookup('route:place');
|
let route = this.owner.lookup('route:place');
|
||||||
assert.ok(route);
|
assert.ok(route);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('afterModel enriches model with missing geometry', async function (assert) {
|
||||||
|
let route = this.owner.lookup('route:place');
|
||||||
|
|
||||||
|
// Mock Services
|
||||||
|
let fetchCalled = false;
|
||||||
|
let selectPlaceCalled = false;
|
||||||
|
|
||||||
|
class OsmStub extends Service {
|
||||||
|
async fetchOsmObject(id, type) {
|
||||||
|
fetchCalled = true;
|
||||||
|
assert.strictEqual(id, '123', 'Correct ID passed');
|
||||||
|
assert.strictEqual(type, 'way', 'Correct Type passed');
|
||||||
|
return {
|
||||||
|
osmId: '123',
|
||||||
|
osmType: 'way',
|
||||||
|
geojson: { type: 'Polygon', coordinates: [] },
|
||||||
|
tags: { updated: 'true' },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class MapUiStub extends Service {
|
||||||
|
selectPlace(place) {
|
||||||
|
selectPlaceCalled = true;
|
||||||
|
}
|
||||||
|
stopSearch() {}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.owner.register('service:osm', OsmStub);
|
||||||
|
this.owner.register('service:map-ui', MapUiStub);
|
||||||
|
|
||||||
|
// Initial partial model (from search)
|
||||||
|
let model = {
|
||||||
|
osmId: '123',
|
||||||
|
osmType: 'way',
|
||||||
|
title: 'Partial Place',
|
||||||
|
// No geojson
|
||||||
|
};
|
||||||
|
|
||||||
|
await route.afterModel(model);
|
||||||
|
|
||||||
|
assert.ok(fetchCalled, 'fetchOsmObject should be called');
|
||||||
|
assert.ok(selectPlaceCalled, 'selectPlace should be called');
|
||||||
|
assert.ok(model.geojson, 'Model should now have geojson');
|
||||||
|
assert.strictEqual(
|
||||||
|
model.tags.updated,
|
||||||
|
'true',
|
||||||
|
'Model should have updated tags'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('afterModel skips fetch if geometry exists', async function (assert) {
|
||||||
|
let route = this.owner.lookup('route:place');
|
||||||
|
|
||||||
|
let fetchCalled = false;
|
||||||
|
|
||||||
|
class OsmStub extends Service {
|
||||||
|
async fetchOsmObject() {
|
||||||
|
fetchCalled = true;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class MapUiStub extends Service {
|
||||||
|
selectPlace() {}
|
||||||
|
stopSearch() {}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.owner.register('service:osm', OsmStub);
|
||||||
|
this.owner.register('service:map-ui', MapUiStub);
|
||||||
|
|
||||||
|
let model = {
|
||||||
|
osmId: '456',
|
||||||
|
osmType: 'relation',
|
||||||
|
geojson: { type: 'MultiLineString' },
|
||||||
|
};
|
||||||
|
|
||||||
|
await route.afterModel(model);
|
||||||
|
|
||||||
|
assert.notOk(
|
||||||
|
fetchCalled,
|
||||||
|
'fetchOsmObject should NOT be called if geojson exists'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('afterModel skips fetch for nodes even if geometry is missing', async function (assert) {
|
||||||
|
let route = this.owner.lookup('route:place');
|
||||||
|
|
||||||
|
let fetchCalled = false;
|
||||||
|
|
||||||
|
class OsmStub extends Service {
|
||||||
|
async fetchOsmObject() {
|
||||||
|
fetchCalled = true;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class MapUiStub extends Service {
|
||||||
|
selectPlace() {}
|
||||||
|
stopSearch() {}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.owner.register('service:osm', OsmStub);
|
||||||
|
this.owner.register('service:map-ui', MapUiStub);
|
||||||
|
|
||||||
|
let model = {
|
||||||
|
osmId: '789',
|
||||||
|
osmType: 'node',
|
||||||
|
// No geojson, but it's a node
|
||||||
|
};
|
||||||
|
|
||||||
|
await route.afterModel(model);
|
||||||
|
|
||||||
|
assert.notOk(fetchCalled, 'fetchOsmObject should NOT be called for nodes');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
254
tests/unit/services/osm-test.js
Normal file
254
tests/unit/services/osm-test.js
Normal file
@@ -0,0 +1,254 @@
|
|||||||
|
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 prioritizes label node for relations', 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, tags: { name: 'Admin Centre' } },
|
||||||
|
{ id: 2, type: 'node', lat: 30, lon: 30, tags: { name: 'Label Node' } },
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = service.normalizeOsmApiData(elements, 789, 'relation');
|
||||||
|
|
||||||
|
assert.strictEqual(result.title, 'Label Node');
|
||||||
|
assert.strictEqual(result.lat, 30);
|
||||||
|
assert.strictEqual(result.lon, 30);
|
||||||
|
assert.strictEqual(result.osmId, '2');
|
||||||
|
assert.strictEqual(result.osmType, 'node');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('normalizeOsmApiData falls back to admin_centre node for relations', function (assert) {
|
||||||
|
let service = this.owner.lookup('service:osm');
|
||||||
|
const elements = [
|
||||||
|
{
|
||||||
|
id: 789,
|
||||||
|
type: 'relation',
|
||||||
|
members: [{ type: 'node', ref: 1, role: 'admin_centre' }],
|
||||||
|
tags: { name: 'Test Relation' },
|
||||||
|
},
|
||||||
|
{ id: 1, type: 'node', lat: 10, lon: 10, tags: { name: 'Admin Centre' } },
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = service.normalizeOsmApiData(elements, 789, 'relation');
|
||||||
|
|
||||||
|
assert.strictEqual(result.title, 'Admin Centre');
|
||||||
|
assert.strictEqual(result.lat, 10);
|
||||||
|
assert.strictEqual(result.lon, 10);
|
||||||
|
assert.strictEqual(result.osmId, '1');
|
||||||
|
assert.strictEqual(result.osmType, 'node');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('normalizeOsmApiData calculates bbox for relations', function (assert) {
|
||||||
|
let service = this.owner.lookup('service:osm');
|
||||||
|
const elements = [
|
||||||
|
{
|
||||||
|
id: 789,
|
||||||
|
type: 'relation',
|
||||||
|
members: [
|
||||||
|
{ type: 'node', ref: 1, role: 'label' },
|
||||||
|
{ type: 'node', ref: 2, role: 'border' },
|
||||||
|
{ type: 'node', ref: 3, role: 'border' },
|
||||||
|
],
|
||||||
|
tags: { name: 'Test Relation' },
|
||||||
|
},
|
||||||
|
{ id: 1, type: 'node', lat: 10, lon: 10, tags: { name: 'Label' } },
|
||||||
|
{ id: 2, type: 'node', lat: 0, lon: 0 },
|
||||||
|
{ id: 3, type: 'node', lat: 20, lon: 20 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = service.normalizeOsmApiData(elements, 789, 'relation');
|
||||||
|
|
||||||
|
// Should prioritize admin centre for ID/Title/Center
|
||||||
|
assert.strictEqual(result.title, 'Label');
|
||||||
|
assert.strictEqual(result.lat, 10);
|
||||||
|
assert.strictEqual(result.lon, 10);
|
||||||
|
assert.strictEqual(result.osmId, '1');
|
||||||
|
assert.strictEqual(result.osmType, 'node');
|
||||||
|
|
||||||
|
// BUT should calculate BBox from ALL members (0,0 to 20,20)
|
||||||
|
assert.ok(result.bbox, 'BBox should be present');
|
||||||
|
assert.strictEqual(result.bbox.minLat, 0);
|
||||||
|
assert.strictEqual(result.bbox.minLon, 0);
|
||||||
|
assert.strictEqual(result.bbox.maxLat, 20);
|
||||||
|
assert.strictEqual(result.bbox.maxLon, 20);
|
||||||
|
});
|
||||||
|
|
||||||
|
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');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('normalizeOsmApiData creates GeoJSON for ways', function (assert) {
|
||||||
|
let service = this.owner.lookup('service:osm');
|
||||||
|
const elements = [
|
||||||
|
{
|
||||||
|
id: 456,
|
||||||
|
type: 'way',
|
||||||
|
nodes: [1, 2, 3],
|
||||||
|
tags: { name: 'Test Way' },
|
||||||
|
},
|
||||||
|
{ id: 1, type: 'node', lat: 0, lon: 0 },
|
||||||
|
{ id: 2, type: 'node', lat: 10, lon: 10 },
|
||||||
|
{ id: 3, type: 'node', lat: 0, lon: 0 }, // Closed loop
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = service.normalizeOsmApiData(elements, 456, 'way');
|
||||||
|
|
||||||
|
assert.ok(result.geojson, 'GeoJSON should be present');
|
||||||
|
assert.strictEqual(
|
||||||
|
result.geojson.type,
|
||||||
|
'Polygon',
|
||||||
|
'Closed way should be a Polygon'
|
||||||
|
);
|
||||||
|
assert.strictEqual(
|
||||||
|
result.geojson.coordinates[0].length,
|
||||||
|
3,
|
||||||
|
'Should have 3 coordinates'
|
||||||
|
);
|
||||||
|
assert.deepEqual(result.geojson.coordinates[0][0], [0, 0]);
|
||||||
|
assert.deepEqual(result.geojson.coordinates[0][1], [10, 10]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('normalizeOsmApiData creates GeoJSON MultiLineString for relations', function (assert) {
|
||||||
|
let service = this.owner.lookup('service:osm');
|
||||||
|
/*
|
||||||
|
Relation 999
|
||||||
|
-> Way 888 (0,0 -> 10,10)
|
||||||
|
-> Way 777 (20,20 -> 30,30)
|
||||||
|
*/
|
||||||
|
const elements = [
|
||||||
|
{
|
||||||
|
id: 999,
|
||||||
|
type: 'relation',
|
||||||
|
members: [
|
||||||
|
{ type: 'way', ref: 888, role: 'outer' },
|
||||||
|
{ type: 'way', ref: 777, role: 'inner' },
|
||||||
|
],
|
||||||
|
tags: { name: 'Complex Relation' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 888,
|
||||||
|
type: 'way',
|
||||||
|
nodes: [1, 2],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 777,
|
||||||
|
type: 'way',
|
||||||
|
nodes: [3, 4],
|
||||||
|
},
|
||||||
|
{ id: 1, type: 'node', lat: 0, lon: 0 },
|
||||||
|
{ id: 2, type: 'node', lat: 10, lon: 10 },
|
||||||
|
{ id: 3, type: 'node', lat: 20, lon: 20 },
|
||||||
|
{ id: 4, type: 'node', lat: 30, lon: 30 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = service.normalizeOsmApiData(elements, 999, 'relation');
|
||||||
|
|
||||||
|
assert.ok(result.geojson, 'GeoJSON should be present');
|
||||||
|
assert.strictEqual(result.geojson.type, 'MultiLineString');
|
||||||
|
assert.strictEqual(
|
||||||
|
result.geojson.coordinates.length,
|
||||||
|
2,
|
||||||
|
'Should have 2 segments'
|
||||||
|
);
|
||||||
|
// Check first segment (Way 888)
|
||||||
|
assert.deepEqual(result.geojson.coordinates[0], [
|
||||||
|
[0, 0],
|
||||||
|
[10, 10],
|
||||||
|
]);
|
||||||
|
// Check second segment (Way 777)
|
||||||
|
assert.deepEqual(result.geojson.coordinates[1], [
|
||||||
|
[20, 20],
|
||||||
|
[30, 30],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
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