Compare commits

49 Commits

Author SHA1 Message Date
a6ca362876 Multi-line rendering for multi-value tags
E.g. opening hours, multiple phone numbers, ...
2026-02-24 14:50:39 +04:00
95e9c621a5 Fix sidebar link layout issue 2026-02-24 13:31:11 +04:00
e980431c17 Add Wikipedia icon, support for filled SVGs 2026-02-24 13:25:17 +04:00
4fdf2e2fb6 WIP Add more icons 2026-02-24 13:04:15 +04:00
de1b162ee9 Different sidebar headers for nearby and full search 2026-02-24 12:49:07 +04:00
1df77c2045 Tweak search box width on small screens 2026-02-24 12:21:03 +04:00
eb1445b749 Update status doc 2026-02-24 11:52:55 +04:00
316a38dbf8 Complete tests for localized names 2026-02-24 11:51:25 +04:00
7bcb572dbf If place key's value is "yes", display key instead
For example, building=yes with no other useful tags (e.g. amenity) will
show as Building now
2026-02-24 11:46:59 +04:00
d827fe263b Draw outlines/areas for ways and relations on map 2026-02-24 11:22:57 +04:00
1926e2b20c Switch back to more reliable Overpass default 2026-02-24 11:05:25 +04:00
df1f32d8bd More place type improvements 2026-02-24 11:05:02 +04:00
aa058bd7a3 Include places that only have localized names
For example "name" absent, but "name:en" present
2026-02-24 10:41:54 +04:00
361a826e4f Improve nearby search
* Use regular expression queries for place types
* Add more place types
* Add relations
* Only return results with a name
2026-02-24 09:58:12 +04:00
ff01d54fdd Update status doc 2026-02-23 23:28:11 +04:00
f73677139d Zoom to fit ways and relations into map view 2026-02-23 22:01:46 +04:00
8135695bba Add waterways (e.g. river) 2026-02-23 21:58:45 +04:00
8217e85836 Improve display of boundaries like cities, states, etc. 2026-02-23 21:14:40 +04:00
d9645d1a8c Fix search result subheadings
Should be address for non-nearby results
2026-02-23 20:28:03 +04:00
688e8eda8d Add more place types 2026-02-23 20:16:24 +04:00
323aab8256 Add more place types, refactor tag usage 2026-02-23 18:02:15 +04:00
ecb3fe4b5a Tweak sizes and layout for icon buttons 2026-02-23 16:50:34 +04:00
43b2700465 Don't start nearby search when unfocusing search by clicking map 2026-02-20 19:48:41 +04:00
00454c8fab Integrate the menu button in the search box
Allows us to make the search box wider, too
2026-02-20 18:35:01 +04:00
bf12305600 Add full-text search
Add a search box with a quick results popover, as well full results in
the sidebar on pressing enter.
2026-02-20 12:39:04 +04:00
2734f08608 Formatting 2026-02-20 12:38:57 +04:00
2aa59f9384 Fetch place details from OSM API, support relations
* Much faster
* Has more place details, which allows us to locate relations, in
  addition to nodes and ways
2026-02-20 12:34:48 +04:00
bcf8ca4255 Add service for Photon requests 2026-02-19 16:28:07 +04:00
20f63065ad 1.11.4 2026-02-10 19:21:34 +04:00
39a7ec3595 Improve code based on linting 2026-02-10 19:20:38 +04:00
32dfa3a30f Merge pull request 'Prefer place name in UA/browser language' (#19) from feature/16-place_names into master
Reviewed-on: #19
2026-02-10 15:20:23 +00:00
64ccc694d3 Prefer place name in UA/browser language
closes #16
2026-02-10 19:19:36 +04:00
87e2380ef6 1.11.3 2026-02-10 18:53:21 +04:00
66c31b19f1 Merge pull request 'UI improvements' (#18) from feature/location_improvements into master
Reviewed-on: #18
2026-02-10 14:52:22 +00:00
55aecbd699 Apply same button-press effect to both header buttons 2026-02-10 18:51:26 +04:00
ccaa56b78f Remove remaining default tap highlights on mobiles 2026-02-10 18:44:41 +04:00
d30375707a Prevent map search when zoomed out too much
It's usually an accidental click, and if not, the search radius/pulse
wouldn't be clearly visible.
2026-02-10 18:33:44 +04:00
53300b92f5 Re-add zoom controls 2026-02-10 17:47:03 +04:00
c37f794eea Auto-locate user on first app launch
closes #17
2026-02-10 17:18:59 +04:00
4bc92bb7cc Run tests before versioning 2026-02-08 17:01:56 +04:00
9f48d7b264 1.11.2 2026-02-08 17:01:01 +04:00
bbd3bf47c6 Merge pull request 'Fix back button behavior' (#14) from bugfix/back_button into master
Reviewed-on: #14
2026-02-08 13:00:07 +00:00
59e3d91071 Fix back button behavior
fixes #12
2026-02-08 16:59:53 +04:00
348b721876 1.11.1 2026-01-27 15:05:08 +07:00
3d982a6a7c More kinetic panning optimizations 2026-01-27 15:04:25 +07:00
0af9d9f16d 1.11.0 2026-01-27 14:24:52 +07:00
a0f132ec64 Disable kinetic panning on mobile by default, add setting for it 2026-01-27 14:23:43 +07:00
925f26ae5d Update status doc 2026-01-27 14:08:27 +07:00
58bb8831f3 Prevent autofocus on mobile
Makes it difficult to fine-tune the location first
2026-01-27 14:06:26 +07:00
40 changed files with 2654 additions and 204 deletions

View File

@@ -1,6 +1,6 @@
# Project Status: Marco
**Last Updated:** Tue Jan 27 2026
**Last Updated:** Tue Feb 24 2026
## Project Context
@@ -36,6 +36,12 @@ 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`).
- **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`).
- **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.
- **Geometry Rendering:**
- **Outlines:** Implemented distinct blue outlines for selected OSM `ways` (Polygons) and `relations` (MultiLineStrings/Polygons) to clearly visualize boundaries.
- **Data Fetching:** Enhanced routing to fetch full geometry data on-demand if the initial search result (e.g., from Photon) lacks it, ensuring outlines are always available.
### 2. RemoteStorage Module (`@remotestorage/module-places`)
@@ -75,6 +81,8 @@ We are building **Marco**, a decentralized maps application using **Ember.js** (
- `app/utils/geohash-coverage.js`: Logic to calculate required 4-char geohash prefixes for a given bounding box.
- **Format Utils:**
- `app/utils/format-text.js` & `humanize-osm-tag` helper: Standardized logic (Title Case, space replacement) for displaying OSM tags like `guest_house` -> "Guest House".
- **Tag refinement:** Improved logic for handling generic tags (e.g., `building=yes`). The UI now intelligently displays the key ("Building") instead of the value ("Yes") for better readability.
- **Localization:** Added basic `navigator.languages` support to `getLocalizedName` for preferring local names when available.
- **Build & DevOps:**
- **Icon Generation:** Added `build:icons` script using `magick` and `rsvg-convert` to automate PNG generation from SVG.
- **Dependencies:** Documented system requirements (ImageMagick, librsvg) in `README.md`.
@@ -92,6 +100,27 @@ We are building **Marco**, a decentralized maps application using **Ember.js** (
- **Smart Linking:** The `showPlaces` action intercepts search results and automatically resolves them to existing **Bookmarks** if a match is found (via `storage.findPlaceById`). This ensures the app navigates to the persistent Bookmark URL (ULID) and correctly reflects the "Saved" status in the UI instead of treating it as a new generic OSM place.
- **Data Normalization:** Refactored `OsmService` to return normalized objects (`osmTags`, `osmType`) for all queries. This ensures consistent data structures between fresh Overpass results and saved bookmarks throughout the app.
### 5. Creation & Editing Workflow
- **Create Place:**
- Implemented `/place/new` route for creating new private places.
- **UX:** Map displays a central crosshair for precise location selection.
- **Mobile Optimization:**
- Disabled map inertia (`kinetic: false`) to ensure the map stops exactly where the finger releases.
- `PlaceEditForm` conditionally disables autofocus on mobile screens (`<= 768px`) to prevent the onscreen keyboard from obscuring the map view immediately.
- Responsive crosshair sizing (48px desktop / 24px mobile).
- **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
- **Repo:** The app runs via `pnpm start`.
@@ -102,20 +131,21 @@ We are building **Marco**, a decentralized maps application using **Ember.js** (
- If direct match: Redirect to `/place/:id`.
- If multiple results: Show `/search` list view.
4. Sidebar displays details via `<PlaceDetails>` component (Bottom sheet on mobile).
5. User clicks "Save Bookmark" -> Stores JSON in RemoteStorage.
6. RemoteStorage change event -> Debounced reload updates the map reactive-ly.
5. **Creation:** User clicks "Create Place" -> Enters creation mode (crosshair) -> Positions map -> Enters details -> Save.
6. **Persistence:** RemoteStorage change event -> Debounced reload updates the map reactive-ly.
7. **Editing:** User can edit the Title and Description of saved bookmarks via an "Edit" button in the details view.
8. **Settings:** User can change the Overpass API provider via the new Settings menu.
## Files Currently in Focus
- `app/services/osm.js`: Caching logic.
- `app/routes/search.js`: Search heuristics.
- `app/components/place-details.gjs`: Formatting logic.
- `app/services/osm.js`
- `app/components/map.gjs`
- `app/routes/place.js`
- `app/utils/osm.js`
## Next Steps & Pending Tasks
1. **Linting & Code Quality:** Fix remaining CSS errors, remove inline styles in `map.gjs`, and address unused variables/runloop usage.
1. **Linting & Code Quality:** Fix remaining CSS errors and address unused variables/runloop usage.
2. **Testing:** Add automated tests for the geohash coverage, retry logic, and new editing features.
3. **Performance:** Monitor performance with large datasets (thousands of bookmarks).

View File

@@ -5,6 +5,7 @@ import { action } from '@ember/object';
import { on } from '@ember/modifier';
import Icon from '#components/icon';
import UserMenu from '#components/user-menu';
import SearchBox from '#components/search-box';
export default class AppHeaderComponent extends Component {
@service storage;
@@ -23,20 +24,13 @@ export default class AppHeaderComponent extends Component {
<template>
<header class="app-header">
<div class="header-left">
<button
class="icon-btn"
type="button"
aria-label="Menu"
{{on "click" @onToggleMenu}}
>
<Icon @name="menu" @size={{24}} @color="#333" />
</button>
<SearchBox @onToggleMenu={{@onToggleMenu}} />
</div>
<div class="header-right">
<div class="user-menu-container">
<button
class="user-btn"
class="user-btn btn-press"
type="button"
aria-label="User Menu"
{{on "click" this.toggleUserMenu}}

View File

@@ -17,11 +17,13 @@ import navigation from 'feather-icons/dist/icons/navigation.svg?raw';
import phone from 'feather-icons/dist/icons/phone.svg?raw';
import plus from 'feather-icons/dist/icons/plus.svg?raw';
import server from 'feather-icons/dist/icons/server.svg?raw';
import search from 'feather-icons/dist/icons/search.svg?raw';
import settings from 'feather-icons/dist/icons/settings.svg?raw';
import target from 'feather-icons/dist/icons/target.svg?raw';
import user from 'feather-icons/dist/icons/user.svg?raw';
import x from 'feather-icons/dist/icons/x.svg?raw';
import zap from 'feather-icons/dist/icons/zap.svg?raw';
import wikipedia from '../icons/wikipedia.svg?raw';
const ICONS = {
'arrow-left': arrowLeft,
@@ -40,9 +42,11 @@ const ICONS = {
phone,
plus,
server,
search,
settings,
target,
user,
wikipedia,
x,
zap,
};
@@ -70,7 +74,11 @@ export default class IconComponent extends Component {
<template>
{{#if this.svg}}
<span class="icon" style={{this.style}} title={{this.title}}>
<span
class="icon {{if @filled 'icon-filled'}}"
style={{this.style}}
title={{this.title}}
>
{{htmlSafe this.svg}}
</span>
{{/if}}

View File

@@ -4,6 +4,8 @@ import { modifier } from 'ember-modifier';
import 'ol/ol.css';
import Map from 'ol/Map.js';
import { defaults as defaultControls, Control } from 'ol/control.js';
import { defaults as defaultInteractions, DragPan } from 'ol/interaction.js';
import Kinetic from 'ol/Kinetic.js';
import View from 'ol/View.js';
import { fromLonLat, toLonLat, getPointResolution } from 'ol/proj.js';
import Overlay from 'ol/Overlay.js';
@@ -11,6 +13,7 @@ import LayerGroup from 'ol/layer/Group.js';
import VectorLayer from 'ol/layer/Vector.js';
import VectorSource from 'ol/source/Vector.js';
import Feature from 'ol/Feature.js';
import GeoJSON from 'ol/format/GeoJSON.js';
import Point from 'ol/geom/Point.js';
import Geolocation from 'ol/Geolocation.js';
import { Style, Circle, Fill, Stroke } from 'ol/style.js';
@@ -21,21 +24,40 @@ export default class MapComponent extends Component {
@service storage;
@service mapUi;
@service router;
@service settings;
mapInstance;
bookmarkSource;
selectedShapeSource;
searchOverlay;
searchOverlayElement;
selectedPinOverlay;
selectedPinElement;
crosshairElement;
crosshairOverlay;
ignoreNextMapClick = false;
setupMap = modifier((element) => {
if (this.mapInstance) return;
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
this.bookmarkSource = new VectorSource();
const bookmarkLayer = new VectorLayer({
@@ -65,6 +87,7 @@ export default class MapComponent extends Component {
// Default view settings
let center = [14.21683569, 27.060114248];
let zoom = 2.661;
let restoredFromStorage = false;
// Try to restore from localStorage
try {
@@ -79,6 +102,7 @@ export default class MapComponent extends Component {
) {
center = parsed.center;
zoom = parsed.zoom;
restoredFromStorage = true;
}
}
} catch (e) {
@@ -93,15 +117,22 @@ export default class MapComponent extends Component {
this.mapInstance = new Map({
target: element,
layers: [openfreemap, bookmarkLayer],
layers: [openfreemap, selectedShapeLayer, bookmarkLayer],
view: view,
controls: defaultControls({
zoom: false,
zoom: true,
rotate: true,
attribution: true,
}),
interactions: defaultInteractions({
dragPan: false, // Disable default DragPan to add a custom one
}),
});
// 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');
this.searchOverlayElement = document.createElement('div');
@@ -147,9 +178,6 @@ export default class MapComponent extends Component {
`;
element.appendChild(this.crosshairElement);
// Geolocation Pulse Overlay
this.locationOverlayElement = document.createElement('div');
this.locationOverlayElement.className = 'search-pulse blue';
@@ -160,6 +188,18 @@ export default class MapComponent extends Component {
});
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
const geolocation = new Geolocation({
trackingOptions: {
@@ -237,6 +277,7 @@ export default class MapComponent extends Component {
const coordinates = geolocation.getPosition();
const accuracyGeometry = geolocation.getAccuracyGeometry();
const accuracy = geolocation.getAccuracy();
console.debug('Geolocation change:', { coordinates, accuracy });
if (!coordinates) return;
@@ -301,7 +342,8 @@ export default class MapComponent extends Component {
this.mapInstance.getView().animate(viewOptions);
};
locateBtn.addEventListener('click', () => {
const startLocating = () => {
console.debug('Getting current geolocation...');
// 1. Clear any previous session
stopLocating();
@@ -325,7 +367,9 @@ export default class MapComponent extends Component {
locateTimeout = setTimeout(() => {
stopLocating();
}, 10000);
});
};
locateBtn.addEventListener('click', startLocating);
const locateControl = new Control({
element: locateElement,
@@ -334,6 +378,11 @@ export default class MapComponent extends Component {
this.mapInstance.addLayer(geolocationLayer);
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);
// Load places when map moves
@@ -353,12 +402,53 @@ export default class MapComponent extends Component {
});
});
updateInteractions = modifier(() => {
if (!this.mapInstance) return;
// Remove existing DragPan interactions
this.mapInstance
.getInteractions()
.getArray()
.slice()
.forEach((interaction) => {
if (interaction instanceof DragPan) {
this.mapInstance.removeInteraction(interaction);
}
});
// Add new DragPan with current setting
const kinetic = this.settings.mapKinetic
? new Kinetic(-0.005, 0.05, 100)
: false;
// Fix for "sticky" touches on mobile:
// If we're on mobile (width <= 768) AND using kinetic,
// we increase the minimum velocity required to trigger kinetic panning.
// This prevents slow drags from being interpreted as a "throw"
if (this.settings.mapKinetic && window.innerWidth <= 768) {
// Default minVelocity is 0.05. We bump it up significantly.
// This means the user has to really "flick" the map to get inertia.
kinetic.minVelocity_ = 0.25;
}
this.mapInstance.addInteraction(
new DragPan({
kinetic: kinetic,
})
);
});
// Track the selected place from the UI Service (Router -> Map)
updateSelectedPin = modifier(() => {
const selected = this.mapUi.selectedPlace;
if (!this.selectedPinOverlay || !this.selectedPinElement) return;
// Clear any previous shape
if (this.selectedShapeSource) {
this.selectedShapeSource.clear();
}
if (selected && selected.lat && selected.lon) {
const coords = fromLonLat([selected.lon, selected.lat]);
this.selectedPinOverlay.setPosition(coords);
@@ -369,7 +459,23 @@ export default class MapComponent extends Component {
void this.selectedPinElement.offsetWidth;
this.selectedPinElement.classList.add('active');
this.handlePinVisibility(coords);
// 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);
}
} else {
this.selectedPinElement.classList.remove('active');
// Hide it effectively by moving it away or just relying on display:none in CSS
@@ -377,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) {
if (!this.mapInstance) return;
@@ -595,18 +750,29 @@ export default class MapComponent extends Component {
handleMapMove = async () => {
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 (this.mapUi.isCreating) {
// Calculate coordinates under the crosshair element
// We need the pixel position of the crosshair relative to the map viewport
// 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 centerX = crosshairRect.left + crosshairRect.width / 2 - mapRect.left;
const centerY = crosshairRect.top + crosshairRect.height / 2 - mapRect.top;
const centerX =
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 lat = parseFloat(center[1].toFixed(6));
@@ -646,6 +812,11 @@ export default class MapComponent extends Component {
};
handleMapClick = async (event) => {
if (this.ignoreNextMapClick) {
this.ignoreNextMapClick = false;
return;
}
// Check if user clicked on a rendered feature (POI or Bookmark) FIRST
const features = this.mapInstance.getFeaturesAtPixel(event.pixel, {
hitTolerance: 10,
@@ -695,6 +866,13 @@ export default class MapComponent extends Component {
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 [lon, lat] = coords;
@@ -724,10 +902,9 @@ export default class MapComponent extends Component {
const queryParams = {
lat: lat.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 });
};
@@ -736,6 +913,7 @@ export default class MapComponent extends Component {
<div
class="map-container {{if @isSidebarOpen 'sidebar-open'}}"
{{this.setupMap}}
{{this.updateInteractions}}
{{this.updateBookmarks}}
{{this.updateSelectedPin}}
{{this.syncPulse}}

View File

@@ -1,7 +1,9 @@
import Component from '@glimmer/component';
import { fn } from '@ember/helper';
import { on } from '@ember/modifier';
import { htmlSafe } from '@ember/template';
import { humanizeOsmTag } from '../utils/format-text';
import { getLocalizedName, getPlaceType } from '../utils/osm';
import Icon from '../components/icon';
import PlaceEditForm from './place-edit-form';
@@ -20,12 +22,7 @@ export default class PlaceDetails extends Component {
}
get name() {
return (
this.place.title ||
this.tags.name ||
this.tags['name:en'] ||
'Unnamed Place'
);
return this.place.title || getLocalizedName(this.tags) || 'Unnamed Place';
}
@action
@@ -51,58 +48,103 @@ export default class PlaceDetails extends Component {
}
get type() {
const rawType =
this.tags.amenity ||
this.tags.shop ||
this.tags.tourism ||
this.tags.leisure ||
this.tags.historic ||
'Point of Interest';
return humanizeOsmTag(rawType);
return getPlaceType(this.tags);
}
get address() {
const t = this.tags;
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
if (t['addr:street']) {
let street = t['addr:street'];
if (t['addr:housenumber']) {
street += ` ${t['addr:housenumber']}`;
let street = get('addr:street', 'street');
const number = get('addr:housenumber', 'housenumber');
if (street) {
if (number) {
street = `${street} ${number}`;
}
parts.push(street);
}
// Postcode + City
if (t['addr:city']) {
let city = t['addr:city'];
if (t['addr:postcode']) {
city = `${t['addr:postcode']} ${city}`;
let city = get('addr:city', 'city');
const postcode = get('addr:postcode', 'postcode');
if (city) {
if (postcode) {
city = `${postcode} ${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;
return parts.join(', ');
}
formatMultiLine(val, type) {
if (!val) return null;
const parts = val.split(';').map((s) => s.trim()).filter(Boolean);
if (parts.length === 0) return null;
if (type === 'phone') {
return htmlSafe(
parts.map((p) => `<a href="tel:${p}">${p}</a>`).join('<br>')
);
}
if (type === 'url') {
return htmlSafe(
parts
.map(
(url) =>
`<a href="${url}" target="_blank" rel="noopener noreferrer">${this.getDomain(
url
)}</a>`
)
.join('<br>')
);
}
return htmlSafe(parts.join('<br>'));
}
get phone() {
return this.tags.phone || this.tags['contact:phone'];
const val = this.tags.phone || this.tags['contact:phone'];
return this.formatMultiLine(val, 'phone');
}
get website() {
return this.place.url || this.tags.website || this.tags['contact:website'];
const val = this.place.url || this.tags.website || this.tags['contact:website'];
return this.formatMultiLine(val, 'url');
}
get websiteDomain() {
const url = new URL(this.website);
return url.hostname;
getDomain(urlStr) {
try {
const url = new URL(urlStr);
return url.hostname;
} catch {
return urlStr;
}
}
get openingHours() {
return this.tags.opening_hours;
const val = this.tags.opening_hours;
return this.formatMultiLine(val);
}
get cuisine() {
@@ -114,7 +156,9 @@ export default class PlaceDetails extends Component {
}
get wikipedia() {
return this.tags.wikipedia;
const val = this.tags.wikipedia;
if (!val) return null;
return val.split(';').map((s) => s.trim()).filter(Boolean)[0];
}
get geoLink() {
@@ -129,7 +173,7 @@ export default class PlaceDetails extends Component {
const lat = this.place.lat;
const lon = this.place.lon;
if (!lat || !lon) return '';
return `${lat}, ${lon}`;
return `${Number(lat).toFixed(6)}, ${Number(lon).toFixed(6)}`;
}
get osmUrl() {
@@ -145,6 +189,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}`;
}
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>
<div class="place-details">
{{#if this.isEditing}}
@@ -158,7 +212,7 @@ export default class PlaceDetails extends Component {
<p class="place-type">
{{this.type}}
</p>
{{#if this.place.description}}
{{#if this.showDescription}}
<p class="place-description">
{{this.place.description}}
</p>
@@ -198,7 +252,7 @@ export default class PlaceDetails extends Component {
<div class="meta-info">
{{#if this.cuisine}}
<p>
<p class="cuisine-info">
<strong>Cuisine:</strong>
{{this.cuisine}}
</p>
@@ -207,36 +261,42 @@ export default class PlaceDetails extends Component {
{{#if this.openingHours}}
<p class="content-with-icon">
<Icon @name="clock" @title="Opening hours" />
<span>{{this.openingHours}}</span>
<span>
{{this.openingHours}}
</span>
</p>
{{/if}}
{{#if this.phone}}
<p class="content-with-icon">
<Icon @name="phone" @title="Phone" />
<span><a href="tel:{{this.phone}}">{{this.phone}}</a></span>
<span>
{{this.phone}}
</span>
</p>
{{/if}}
{{#if this.website}}
<p class="content-with-icon">
<Icon @name="globe" @title="Website" />
<span><a
href={{this.website}}
target="_blank"
rel="noopener noreferrer"
>{{this.websiteDomain}}</a></span>
<span>
{{this.website}}
</span>
</p>
{{/if}}
{{#if this.wikipedia}}
<p>
<strong>Wikipedia:</strong>
<a
href="https://wikipedia.org/wiki/{{this.wikipedia}}"
target="_blank"
rel="noopener noreferrer"
>Article</a>
<p class="content-with-icon">
<Icon @name="wikipedia" @title="Wikipedia" @filled={{true}} />
<span>
<a
href="https://wikipedia.org/wiki/{{this.wikipedia}}"
target="_blank"
rel="noopener noreferrer"
>
Wikipedia
</a>
</span>
</p>
{{/if}}
@@ -274,7 +334,11 @@ export default class PlaceDetails extends Component {
<p class="content-with-icon">
<Icon @name="map" />
<span>
<a href={{this.gmapsUrl}} target="_blank" rel="noopener noreferrer">
<a
href={{this.gmapsUrl}}
target="_blank"
rel="noopener noreferrer"
>
Google Maps
</a>
</span>

View File

@@ -13,6 +13,13 @@ export default class PlaceEditForm extends Component {
this.description = this.args.place?.description || '';
}
get shouldAutofocus() {
if (typeof window !== 'undefined') {
return window.innerWidth > 768;
}
return false;
}
@action
handleSubmit(event) {
event.preventDefault();
@@ -45,7 +52,7 @@ export default class PlaceEditForm extends Component {
{{on "input" this.updateTitle}}
class="form-control"
placeholder="Name of the place"
autofocus
autofocus={{this.shouldAutofocus}}
/>
</div>
<div class="form-group">

View File

@@ -4,9 +4,11 @@ import { action } from '@ember/object';
import { on } from '@ember/modifier';
import { fn } from '@ember/helper';
import or from 'ember-truth-helpers/helpers/or';
import eq from 'ember-truth-helpers/helpers/eq';
import PlaceDetails from './place-details';
import Icon from './icon';
import humanizeOsmTag from '../helpers/humanize-osm-tag';
import { getLocalizedName, getPlaceType } from '../utils/osm';
export default class PlacesSidebar extends Component {
@service storage;
@@ -22,8 +24,10 @@ export default class PlacesSidebar extends Component {
if (lat && lon) {
this.router.transitionTo('place.new', { queryParams: { lat, lon } });
} else {
// Fallback (shouldn't happen in search context)
this.router.transitionTo('place.new', { queryParams: { lat: 0, lon: 0 } });
// Fallback (shouldn't happen in search context)
this.router.transitionTo('place.new', {
queryParams: { lat: 0, lon: 0 },
});
}
}
@@ -85,8 +89,7 @@ export default class PlacesSidebar extends Component {
} else {
// It's a fresh POI -> Save it
const placeData = {
title:
place.osmTags.name || place.osmTags['name:en'] || 'Untitled Place',
title: getLocalizedName(place.osmTags, 'Untitled Place'),
lat: place.lat,
lon: place.lon,
tags: [],
@@ -142,6 +145,11 @@ export default class PlacesSidebar extends Component {
}
}
get isNearbySearch() {
const qp = this.router.currentRoute.queryParams;
return !qp.q && qp.lat && qp.lon;
}
<template>
<div class="sidebar">
<div class="sidebar-header">
@@ -152,7 +160,11 @@ export default class PlacesSidebar extends Component {
{{on "click" this.clearSelection}}
><Icon @name="arrow-left" @size={{20}} @color="#333" /></button>
{{else}}
<h2><Icon @name="target" @size={{20}} @color="#ea4335" /> Nearby</h2>
{{#if this.isNearbySearch}}
<h2><Icon @name="target" @size={{20}} @color="#ea4335" /> Nearby</h2>
{{else}}
<h2><Icon @name="search" @size={{20}} @color="#333" /> Results</h2>
{{/if}}
{{/if}}
<button type="button" class="close-btn" {{on "click" @onClose}}><Icon
@name="x"
@@ -184,20 +196,29 @@ export default class PlacesSidebar extends Component {
place.osmTags.name:en
"Unnamed Place"
}}</div>
<div class="place-type">{{humanizeOsmTag (or
place.osmTags.amenity
place.osmTags.shop
place.osmTags.tourism
place.osmTags.leisure
place.osmTags.historic
"Point of Interest"
)}}</div>
<div class="place-type">
{{#if (eq place.source "osm")}}
{{humanizeOsmTag place.type}}
{{else if (eq place.source "photon")}}
{{place.description}}
{{else}}
{{#if place.osmTags}}
{{humanizeOsmTag (getPlaceType place.osmTags)}}
{{else if place.description}}
{{place.description}}
{{/if}}
{{/if}}
</div>
</button>
</li>
{{/each}}
</ul>
{{else}}
<p class="empty-state">No places found nearby.</p>
{{#if this.isNearbySearch}}
<p class="empty-state">No places found nearby.</p>
{{else}}
<p class="empty-state">No results found.</p>
{{/if}}
{{/if}}
<button

View 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>
}

View File

@@ -4,6 +4,7 @@ import { service } from '@ember/service';
import { action } from '@ember/object';
import Icon from '#components/icon';
import eq from 'ember-truth-helpers/helpers/eq';
import not from 'ember-truth-helpers/helpers/not';
export default class SettingsPane extends Component {
@service settings;
@@ -13,6 +14,11 @@ export default class SettingsPane extends Component {
this.settings.updateOverpassApi(event.target.value);
}
@action
toggleKinetic(event) {
this.settings.updateMapKinetic(event.target.value === 'true');
}
<template>
<div class="sidebar settings-pane">
<div class="sidebar-header">
@@ -25,6 +31,27 @@ export default class SettingsPane extends Component {
<div class="sidebar-content">
<section class="settings-section">
<h3>Settings</h3>
<div class="form-group">
<label for="map-kinetic">Map Inertia (Kinetic Panning)</label>
<select
id="map-kinetic"
class="form-control"
{{on "change" this.toggleKinetic}}
>
<option
value="true"
selected={{if this.settings.mapKinetic "selected"}}
>
On
</option>
<option
value="false"
selected={{unless this.settings.mapKinetic "selected"}}
>
Off
</option>
</select>
</div>
<div class="form-group">
<label for="overpass-api">Overpass API Provider</label>
<select
@@ -35,7 +62,10 @@ export default class SettingsPane extends Component {
{{#each this.settings.overpassApis as |api|}}
<option
value={{api.url}}
selected={{if (eq api.url this.settings.overpassApi) "selected"}}
selected={{if
(eq api.url this.settings.overpassApi)
"selected"
}}
>
{{api.name}}
</option>
@@ -46,24 +76,45 @@ export default class SettingsPane extends Component {
<section class="settings-section">
<h3>About</h3>
<p>
<strong>Marco</strong> (as in <a
href="https://en.wikipedia.org/wiki/Marco_Polo"
target="_blank" rel="noopener">Marco Polo</a>) is an unhosted maps application
that respects your privacy and choices.
<strong>Marco</strong>
(as in
<a
href="https://en.wikipedia.org/wiki/Marco_Polo"
target="_blank"
rel="noopener"
>Marco Polo</a>) is an unhosted maps application that respects your
privacy and choices.
</p>
<p>
Connect your own <a href="https://remotestorage.io/"
target="_blank" rel="noopener">remote storage</a> to sync place bookmarks across
apps and devices.
Connect your own
<a
href="https://remotestorage.io/"
target="_blank"
rel="noopener"
>remote storage</a>
to sync place bookmarks across apps and devices.
</p>
<ul class="link-list">
<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
</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>
<a href="https://openstreetmap.org/copyright" target="_blank" rel="noopener">
<a
href="https://openstreetmap.org/copyright"
target="_blank"
rel="noopener"
>
Map Data © OpenStreetMap
</a>
</li>

10
app/controllers/search.js Normal file
View 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;
}

4
app/icons/wikipedia.svg Normal file
View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="128" height="128" viewBox="7.15 7.15 113.7 113.7" fill="currentColor">
<path d="M 120.85,29.21 C 120.85,29.62 120.72,29.99 120.47,30.33 C 120.21,30.66 119.94,30.83 119.63,30.83 C 117.14,31.07 115.09,31.87 113.51,33.24 C 111.92,34.6 110.29,37.21 108.6,41.05 L 82.8,99.19 C 82.63,99.73 82.16,100 81.38,100 C 80.77,100 80.3,99.73 79.96,99.19 L 65.49,68.93 L 48.85,99.19 C 48.51,99.73 48.04,100 47.43,100 C 46.69,100 46.2,99.73 45.96,99.19 L 20.61,41.05 C 19.03,37.44 17.36,34.92 15.6,33.49 C 13.85,32.06 11.4,31.17 8.27,30.83 C 8,30.83 7.74,30.69 7.51,30.4 C 7.27,30.12 7.15,29.79 7.15,29.42 C 7.15,28.47 7.42,28 7.96,28 C 10.22,28 12.58,28.1 15.05,28.3 C 17.34,28.51 19.5,28.61 21.52,28.61 C 23.58,28.61 26.01,28.51 28.81,28.3 C 31.74,28.1 34.34,28 36.6,28 C 37.14,28 37.41,28.47 37.41,29.42 C 37.41,30.36 37.24,30.83 36.91,30.83 C 34.65,31 32.87,31.58 31.57,32.55 C 30.27,33.53 29.62,34.81 29.62,36.4 C 29.62,37.21 29.89,38.22 30.43,39.43 L 51.38,86.74 L 63.27,64.28 L 52.19,41.05 C 50.2,36.91 48.56,34.23 47.28,33.03 C 46,31.84 44.06,31.1 41.46,30.83 C 41.22,30.83 41,30.69 40.78,30.4 C 40.56,30.12 40.45,29.79 40.45,29.42 C 40.45,28.47 40.68,28 41.16,28 C 43.42,28 45.49,28.1 47.38,28.3 C 49.2,28.51 51.14,28.61 53.2,28.61 C 55.22,28.61 57.36,28.51 59.62,28.3 C 61.95,28.1 64.24,28 66.5,28 C 67.04,28 67.31,28.47 67.31,29.42 C 67.31,30.36 67.15,30.83 66.81,30.83 C 62.29,31.14 60.03,32.42 60.03,34.68 C 60.03,35.69 60.55,37.26 61.6,39.38 L 68.93,54.26 L 76.22,40.65 C 77.23,38.73 77.74,37.11 77.74,35.79 C 77.74,32.69 75.48,31.04 70.96,30.83 C 70.55,30.83 70.35,30.36 70.35,29.42 C 70.35,29.08 70.45,28.76 70.65,28.46 C 70.86,28.15 71.06,28 71.26,28 C 72.88,28 74.87,28.1 77.23,28.3 C 79.49,28.51 81.35,28.61 82.8,28.61 C 83.84,28.61 85.38,28.52 87.4,28.35 C 89.96,28.12 92.11,28 93.83,28 C 94.23,28 94.43,28.4 94.43,29.21 C 94.43,30.29 94.06,30.83 93.32,30.83 C 90.69,31.1 88.57,31.83 86.97,33.01 C 85.37,34.19 83.37,36.87 80.98,41.05 L 71.26,59.02 L 84.42,85.83 L 103.85,40.65 C 104.52,39 104.86,37.48 104.86,36.1 C 104.86,32.79 102.6,31.04 98.08,30.83 C 97.67,30.83 97.47,30.36 97.47,29.42 C 97.47,28.47 97.77,28 98.38,28 C 100.03,28 101.99,28.1 104.25,28.3 C 106.34,28.51 108.1,28.61 109.51,28.61 C 111,28.61 112.72,28.51 114.67,28.3 C 116.7,28.1 118.52,28 120.14,28 C 120.61,28 120.85,28.4 120.85,29.21 z" />
</svg>

After

Width:  |  Height:  |  Size: 2.4 KiB

View File

@@ -9,7 +9,11 @@ export default class PlaceRoute extends Route {
async model(params) {
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(':');
console.debug(`Fetching explicit OSM ${type}:`, osmId);
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
if (model) {
this.mapUi.selectPlace(model);
@@ -56,11 +81,14 @@ export default class PlaceRoute extends Route {
deactivate() {
// Clear the pin when leaving the route
this.mapUi.clearSelection();
// Reset the "return to search" flag so it doesn't persist to subsequent navigations
this.mapUi.returnToSearch = false;
}
async loadOsmPlace(id, type = null) {
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) {
console.debug('Found OSM POI:', poi);
return poi;

View File

@@ -5,6 +5,7 @@ import { getDistance } from '../utils/geo';
export default class SearchRoute extends Route {
@service osm;
@service photon;
@service mapUi;
@service storage;
@service router;
@@ -13,50 +14,76 @@ export default class SearchRoute extends Route {
lat: { refreshModel: true },
lon: { refreshModel: true },
q: { refreshModel: true },
selected: { refreshModel: true },
};
async model(params) {
// If no coordinates, we can't search
if (!params.lat || !params.lon) {
return [];
const lat = params.lat ? parseFloat(params.lat) : null;
const lon = params.lon ? parseFloat(params.lon) : null;
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);
const lon = parseFloat(params.lon);
const searchRadius = params.q ? 30 : 50;
// Fetch POIs from Overpass
pois = await this.osm.getNearbyPois(lat, lon, searchRadius);
// Fetch POIs
let pois = await this.osm.getNearbyPois(lat, lon, searchRadius);
// Get cached/saved places in search radius
const localMatches = this.storage.savedPlaces.filter((p) => {
const dist = getDistance(lat, lon, p.lat, p.lon);
return dist <= searchRadius;
});
// Get cached/saved places in search radius
const localMatches = this.storage.savedPlaces.filter((p) => {
const dist = getDistance(lat, lon, p.lat, p.lon);
return dist <= searchRadius;
});
// Merge local matches
localMatches.forEach((local) => {
const exists = pois.find(
(poi) =>
(local.osmId && poi.osmId === local.osmId) ||
(poi.id && poi.id === local.id)
);
// Add local matches to the list if they aren't already there
// We use osmId to deduplicate if possible
localMatches.forEach((local) => {
const exists = pois.find(
(poi) =>
(local.osmId && poi.osmId === local.osmId) ||
(poi.id && poi.id === local.id)
);
if (!exists) {
pois.push(local);
}
});
if (!exists) {
pois.push(local);
}
});
// Sort by distance from click
pois = pois
.map((p) => {
return {
...p,
_distance: getDistance(lat, lon, p.lat, p.lon),
};
})
.sort((a, b) => a._distance - b._distance);
// Sort by distance from click
pois = pois
.map((p) => {
return {
...p,
_distance: getDistance(lat, lon, p.lat, p.lon),
};
})
.sort((a, b) => a._distance - b._distance);
}
// Check if any of these are already bookmarked
// We resolve them to the bookmark version if they exist
@@ -69,18 +96,24 @@ export default class SearchRoute extends Route {
}
afterModel(model, transition) {
const { q } = transition.to.queryParams;
const { q, selected } = transition.to.queryParams;
// 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;
// 1. Exact Name Match
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.
// 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.

View File

@@ -6,6 +6,9 @@ export default class MapUiService extends Service {
@tracked isSearching = false;
@tracked isCreating = false;
@tracked creationCoordinates = null;
@tracked returnToSearch = false;
@tracked currentCenter = null;
@tracked searchBoxHasFocus = false;
selectPlace(place) {
this.selectedPlace = place;
@@ -37,4 +40,12 @@ export default class MapUiService extends Service {
updateCreationCoordinates(lat, lon) {
this.creationCoordinates = { lat, lon };
}
setSearchBoxFocus(isFocused) {
this.searchBoxHasFocus = isFocused;
}
updateCenter(lat, lon) {
this.currentCenter = { lat, lon };
}
}

View File

@@ -1,4 +1,5 @@
import Service, { service } from '@ember/service';
import { getLocalizedName, getPlaceType } from '../utils/osm';
export default class OsmService extends Service {
@service settings;
@@ -23,14 +24,31 @@ export default class OsmService extends Service {
this.controller = new AbortController();
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 = `
[out:json][timeout:25];
(
nw["amenity"](around:${radius},${lat},${lon});
nw["shop"](around:${radius},${lat},${lon});
nw["tourism"](around:${radius},${lat},${lon});
nw["leisure"](around:${radius},${lat},${lon});
nw["historic"](around:${radius},${lat},${lon});
node(around:${radius},${lat},${lon})
[${typeKeysQuery}][~"^name"~"."];
way(around:${radius},${lat},${lon})
[${typeKeysQuery}][~"^name"~"."];
relation(around:${radius},${lat},${lon})
[${typeKeysQuery}][~"^name"~"."];
);
out center;
`.trim();
@@ -60,15 +78,20 @@ out center;
}
normalizePoi(poi) {
const tags = poi.tags || {};
const type = getPlaceType(tags) || 'Point of Interest';
return {
title: poi.tags?.name || poi.tags?.['name:en'] || 'Untitled Place',
title: getLocalizedName(tags),
lat: poi.lat || poi.center?.lat,
lon: poi.lon || poi.center?.lon,
url: poi.tags?.website,
url: tags.website,
osmId: String(poi.id),
osmType: poi.type,
osmTags: poi.tags || {},
description: poi.tags?.description,
osmTags: tags,
description: tags.description,
source: 'osm',
type: type,
};
}
@@ -123,4 +146,211 @@ out center;
if (!data.elements[0]) return null;
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
View 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;
}
}
}

View File

@@ -2,15 +2,26 @@ import Service from '@ember/service';
import { tracked } from '@glimmer/tracking';
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;
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',
},
// {
// name: 'overpass.openstreetmap.us (US)',
// url: 'https://overpass.openstreetmap.us/api/interpreter'
// },
// {
// name: 'bke.ro (US)',
// url: 'https://overpass.bke.ro/api/interpreter'
// },
];
constructor() {
@@ -19,14 +30,25 @@ export default class SettingsService extends Service {
}
loadSettings() {
const savedApi = localStorage.getItem('marco-overpass-api');
const savedApi = localStorage.getItem('marco:overpass-api');
if (savedApi) {
this.overpassApi = savedApi;
}
const savedKinetic = localStorage.getItem('marco:map-kinetic');
if (savedKinetic !== null) {
this.mapKinetic = savedKinetic === 'true';
}
// Default is true (initialized in class field)
}
updateOverpassApi(url) {
this.overpassApi = url;
localStorage.setItem('marco-overpass-api', url);
localStorage.setItem('marco:overpass-api', url);
}
updateMapKinetic(enabled) {
this.mapKinetic = enabled;
localStorage.setItem('marco:map-kinetic', String(enabled));
}
}

View File

@@ -5,11 +5,16 @@ body {
height: 100%;
overscroll-behavior: none; /* Prevent pull-to-refresh on mobile */
-webkit-text-size-adjust: 100%;
-webkit-tap-highlight-color: transparent;
}
button {
-webkit-tap-highlight-color: transparent;
}
body {
margin: 0;
font-family: 'Noto Serif', sans-serif;
font-family: 'Noto Sans', sans-serif;
font-size: 16px;
color: #333;
}
@@ -56,7 +61,7 @@ body {
left: 0;
right: 0;
height: 60px;
padding: 0 1rem;
padding: 0.5rem 1rem;
display: flex;
justify-content: space-between;
align-items: center;
@@ -64,26 +69,27 @@ body {
pointer-events: none; /* Let clicks pass through to map where transparent */
}
@media (width <= 768px) {
.app-header {
padding: 0 0.5rem;
}
}
.header-left,
.header-right {
pointer-events: auto; /* Re-enable clicks for buttons */
}
.icon-btn {
background: white;
border: none;
border-radius: 50%;
width: 40px;
height: 40px;
.header-left {
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 2px 5px rgb(0 0 0 / 20%);
cursor: pointer;
}
.btn-press {
transition: transform 0.1s;
}
.icon-btn:active {
.btn-press:active {
transform: scale(0.95);
}
@@ -203,7 +209,6 @@ body {
z-index: 3200; /* Higher than Places Sidebar (3100) */
}
/* Settings Pane Mobile Overrides */
@media (width <= 768px) {
.settings-pane.sidebar {
width: 100%;
@@ -340,7 +345,6 @@ body {
.meta-info a {
color: #007bff;
text-decoration: none;
padding-bottom: 4rem;
}
.meta-info a:hover {
@@ -539,24 +543,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 {
inset: auto 0.5em 2.5em auto;
bottom: 6.5rem;
}
.ol-touch .ol-control.ol-locate {
inset: auto 0.5em 3.5em auto;
bottom: 8.5rem;
}
/* Rotate Control */
.ol-rotate {
inset: auto 0.5em 5em auto;
.ol-control.ol-rotate {
bottom: 9rem;
}
.ol-touch .ol-control.ol-rotate {
bottom: 11.5rem;
}
.ol-touch .ol-rotate {
inset: auto 0.5em 6em auto;
.ol-control.ol-attribution,
.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 {
display: inline-block;
}
@@ -575,13 +609,22 @@ span.icon {
stroke-linejoin: round;
}
.icon-filled svg {
stroke: none;
fill: currentcolor;
}
.content-with-icon {
display: flex;
flex-direction: row;
align-items: center;
align-items: flex-start;
gap: 0.5rem;
}
.content-with-icon .icon {
margin-top: 0.15rem;
}
/* Selected Pin Animation */
.selected-pin-container {
position: absolute;
@@ -724,3 +767,215 @@ button.create-place {
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 */
}
@media (width <= 768px) {
.search-box {
max-width: calc(100vw - 65px);
}
}
.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;
}

View File

@@ -17,8 +17,8 @@ export default class ApplicationComponent extends Component {
@tracked isSettingsOpen = false;
get isSidebarOpen() {
// We consider the sidebar "open" if we are in search or place routes.
// This helps the map know if it should shift the center or adjust view.
// We consider the sidebar "open" if we are in search or place routes.
// This helps the map know if it should shift the center or adjust view.
return (
this.router.currentRouteName === 'place' ||
this.router.currentRouteName === 'place.new' ||
@@ -48,12 +48,12 @@ export default class ApplicationComponent extends Component {
if (this.isSettingsOpen) {
this.closeSettings();
} else if (this.router.currentRouteName === 'search') {
this.router.transitionTo('index');
this.router.transitionTo('index');
} else if (this.router.currentRouteName === 'place') {
// If in place route, decide if we want to go back to search or index
// For now, let's go to index or maybe back to search if search params exist?
// Simplest behavior: clear selection
this.router.transitionTo('index');
// If in place route, decide if we want to go back to search or index
// For now, let's go to index or maybe back to search if search params exist?
// Simplest behavior: clear selection
this.router.transitionTo('index');
}
}

View File

@@ -77,11 +77,11 @@ export default class PlaceTemplate extends Component {
navigateBack(place) {
// The sidebar calls this with null when "Back" is clicked.
if (place === null) {
// If we have history, go back (preserves search state)
if (window.history.length > 1) {
// If we came from search results, go back in history
if (this.mapUi.returnToSearch) {
window.history.back();
} else {
// Fallback if opened directly
// Otherwise just close the sidebar (return to map index)
this.router.transitionTo('index');
}
} else {

View File

@@ -5,10 +5,12 @@ import { action } from '@ember/object';
export default class SearchTemplate extends Component {
@service router;
@service mapUi;
@action
selectPlace(place) {
if (place) {
this.mapUi.returnToSearch = true;
this.router.transitionTo('place', place);
}
}

70
app/utils/osm.js Normal file
View File

@@ -0,0 +1,70 @@
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;
}
const PLACE_TYPE_KEYS = [
'amenity',
'shop',
'tourism',
'historic',
'leisure',
'office',
'craft',
'building',
'landuse',
'public_transport',
'highway',
'aeroway',
'waterway',
'natural',
'place',
'border_type',
'admin_title',
];
export function getPlaceType(tags) {
if (!tags) return null;
for (const key of PLACE_TYPE_KEYS) {
const value = tags[key];
if (value) {
if (value === 'yes') {
return humanizeOsmTag(key);
}
return humanizeOsmTag(value);
}
}
return null;
}

View File

@@ -2,6 +2,9 @@ import { dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { setConfig } from '@warp-drive/core/build-config';
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({
configure: (config) => {
@@ -14,6 +17,7 @@ const macros = buildMacros({
export default {
plugins: [
asyncArrowTaskTransform,
[
'babel-plugin-ember-template-compilation',
{

View File

@@ -1,6 +1,6 @@
{
"name": "marco",
"version": "1.10.1",
"version": "1.11.4",
"private": true,
"description": "Unhosted maps app",
"repository": {
@@ -34,6 +34,7 @@
"lint:js:fix": "eslint . --fix",
"start": "vite",
"test": "vite build --mode development && testem ci --port 0",
"preversion": "pnpm test",
"version": "pnpm build && git add release/"
},
"devDependencies": {
@@ -100,6 +101,7 @@
"edition": "octane"
},
"dependencies": {
"ember-concurrency": "^5.2.0",
"ember-lifeline": "^7.0.0"
}
}

46
pnpm-lock.yaml generated
View File

@@ -8,6 +8,9 @@ importers:
.:
dependencies:
ember-concurrency:
specifier: ^5.2.0
version: 5.2.0(@babel/core@7.28.6)
ember-lifeline:
specifier: ^7.0.0
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==}
cpu: [arm]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-arm-musleabihf@4.55.1':
resolution: {integrity: sha512-grPNWydeKtc1aEdrJDWk4opD7nFtQbMmV7769hiAaYyUKCT1faPRm2av8CX1YJsZ4TLAZcg9gTR1KvEzoLjXkg==}
cpu: [arm]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-arm64-gnu@4.55.1':
resolution: {integrity: sha512-a59mwd1k6x8tXKcUxSyISiquLwB5pX+fJW9TkWU46lCqD/GRDe9uDN31jrMmVP3feI3mhAdvcCClhV8V5MhJFQ==}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-arm64-musl@4.55.1':
resolution: {integrity: sha512-puS1MEgWX5GsHSoiAsF0TYrpomdvkaXm0CofIMG5uVkP6IBV+ZO9xhC5YEN49nsgYo1DuuMquF9+7EDBVYu4uA==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-loong64-gnu@4.55.1':
resolution: {integrity: sha512-r3Wv40in+lTsULSb6nnoudVbARdOwb2u5fpeoOAZjFLznp6tDU8kd+GTHmJoqZ9lt6/Sys33KdIHUaQihFcu7g==}
cpu: [loong64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-loong64-musl@4.55.1':
resolution: {integrity: sha512-MR8c0+UxAlB22Fq4R+aQSPBayvYa3+9DrwG/i1TKQXFYEaoW3B5b/rkSRIypcZDdWjWnpcvxbNaAJDcSbJU3Lw==}
cpu: [loong64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-ppc64-gnu@4.55.1':
resolution: {integrity: sha512-3KhoECe1BRlSYpMTeVrD4sh2Pw2xgt4jzNSZIIPLFEsnQn9gAnZagW9+VqDqAHgm1Xc77LzJOo2LdigS5qZ+gw==}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-ppc64-musl@4.55.1':
resolution: {integrity: sha512-ziR1OuZx0vdYZZ30vueNZTg73alF59DicYrPViG0NEgDVN8/Jl87zkAPu4u6VjZST2llgEUjaiNl9JM6HH1Vdw==}
cpu: [ppc64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-riscv64-gnu@4.55.1':
resolution: {integrity: sha512-uW0Y12ih2XJRERZ4jAfKamTyIHVMPQnTZcQjme2HMVDAHY4amf5u414OqNYC+x+LzRdRcnIG1YodLrrtA8xsxw==}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-riscv64-musl@4.55.1':
resolution: {integrity: sha512-u9yZ0jUkOED1BFrqu3BwMQoixvGHGZ+JhJNkNKY/hyoEgOwlqKb62qu+7UjbPSHYjiVy8kKJHvXKv5coH4wDeg==}
cpu: [riscv64]
os: [linux]
libc: [musl]
'@rollup/rollup-linux-s390x-gnu@4.55.1':
resolution: {integrity: sha512-/0PenBCmqM4ZUd0190j7J0UsQ/1nsi735iPRakO8iPciE7BQ495Y6msPzaOmvx0/pn+eJVVlZrNrSh4WSYLxNg==}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-x64-gnu@4.55.1':
resolution: {integrity: sha512-a8G4wiQxQG2BAvo+gU6XrReRRqj+pLS2NGXKm8io19goR+K8lw269eTrPkSdDTALwMmJp4th2Uh0D8J9bEV1vg==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@rollup/rollup-linux-x64-musl@4.55.1':
resolution: {integrity: sha512-bD+zjpFrMpP/hqkfEcnjXWHMw5BIghGisOKPj+2NaNDuVT+8Ds4mPf3XcPHuat1tz89WRL+1wbcxKY3WSbiT7w==}
cpu: [x64]
os: [linux]
libc: [musl]
'@rollup/rollup-openbsd-x64@4.55.1':
resolution: {integrity: sha512-eLXw0dOiqE4QmvikfQ6yjgkg/xDM+MdU9YJuP4ySTibXU0oAvnEWXt7UDJmD4UkYialMfOGFPJnIHSe/kdzPxg==}
@@ -2519,6 +2535,9 @@ packages:
decimal.js@10.6.0:
resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==}
decorator-transforms@1.2.1:
resolution: {integrity: sha512-UUtmyfdlHvYoX3VSG1w5rbvBQ2r5TX1JsE4hmKU9snleFymadA3VACjl6SRfi9YgBCSjBbfQvR1bs9PRW9yBKw==}
decorator-transforms@2.3.1:
resolution: {integrity: sha512-PDOk74Zqqy0946Lx4ckXxbgG6uhPScOICtrxL/pXmfznxchqNee0TaJISClGJQe6FeT8ohGqsOgdjfahm4FwEw==}
@@ -2669,6 +2688,15 @@ packages:
engines: {node: '>= 20.19.0'}
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:
resolution: {integrity: sha512-b6ALDaxs9Bb4v0uagWud/5lECb78qpXHFv7M340dUHFW4Y0RuhlsfA4Rb+765X1+6KHp8G7TaAs0UgggWUqD3g==}
engines: {node: '>=16.0.0'}
@@ -8110,6 +8138,13 @@ snapshots:
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):
dependencies:
'@babel/plugin-syntax-decorators': 7.28.6(@babel/core@7.28.6)
@@ -8462,6 +8497,17 @@ snapshots:
- walrus
- 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):
dependencies:
'@babel/core': 7.28.6

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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -26,8 +26,8 @@
<meta name="msapplication-TileColor" content="#F6E9A6">
<meta name="msapplication-TileImage" content="/icons/icon-144.png">
<script type="module" crossorigin src="/assets/main-Dep3TjPE.js"></script>
<link rel="stylesheet" crossorigin href="/assets/main-D53xPL_H.css">
<script type="module" crossorigin src="/assets/main-ji2SNMnp.js"></script>
<link rel="stylesheet" crossorigin href="/assets/main-G8wPYi_P.css">
</head>
<body>
</body>

View File

@@ -0,0 +1,114 @@
import { module, test } from 'qunit';
import { visit, currentURL, click, settled } from '@ember/test-helpers';
import { setupApplicationTest } from 'marco/tests/helpers';
import Service from '@ember/service';
import sinon from 'sinon';
class MockOsmService extends Service {
async getNearbyPois() {
return [
{
osmId: '123',
lat: 1,
lon: 1,
osmTags: { name: 'Test Place', amenity: 'cafe' },
osmType: 'node',
},
];
}
async getPoiById() {
return {
osmId: '123',
lat: 1,
lon: 1,
osmTags: { name: 'Test Place', amenity: 'cafe' },
osmType: 'node',
};
}
async fetchOsmObject(id, type) {
return {
osmId: id,
osmType: type,
lat: 1,
lon: 1,
osmTags: { name: 'Test Place', amenity: 'cafe' },
title: 'Test Place',
};
}
}
class MockStorageService extends Service {
savedPlaces = [];
findPlaceById() {
return null;
}
loadPlacesInBounds() {
return [];
}
get placesInView() {
return [];
}
rs = {
on: () => {},
};
}
module('Acceptance | navigation', function (hooks) {
setupApplicationTest(hooks);
hooks.beforeEach(function () {
this.owner.register('service:osm', MockOsmService);
this.owner.register('service:storage', MockStorageService);
});
test('navigating from search results to place and back uses history', async function (assert) {
const mapUi = this.owner.lookup('service:map-ui');
const backStub = sinon.stub(window.history, 'back');
try {
await visit('/search?lat=1&lon=1');
assert.strictEqual(currentURL(), '/search?lat=1&lon=1');
await click('.place-item');
assert.ok(currentURL().includes('/place/'), 'Navigated to place');
assert.true(mapUi.returnToSearch, 'Flag returnToSearch is set');
// Click the back button in the sidebar
await click('.back-btn');
assert.true(backStub.calledOnce, 'window.history.back() was called');
} finally {
backStub.restore();
}
});
test('closing the sidebar resets the returnToSearch flag', async function (assert) {
const mapUi = this.owner.lookup('service:map-ui');
await visit('/search?lat=1&lon=1');
await click('.place-item'); // Sets returnToSearch = true
assert.true(mapUi.returnToSearch, 'Flag is set upon entering place');
// Click the Close (X) button
await click('.close-btn');
assert.strictEqual(currentURL(), '/', 'Returned to index');
assert.false(mapUi.returnToSearch, 'Flag is reset after closing sidebar');
});
test('navigating directly to place and back closes sidebar', async function (assert) {
const backStub = sinon.stub(window.history, 'back');
try {
await visit('/place/osm:node:123');
assert.ok(currentURL().includes('/place/'), 'Visited place directly');
await click('.back-btn');
assert.strictEqual(currentURL(), '/', 'Returned to index/map');
assert.true(backStub.notCalled, 'window.history.back() was NOT called');
} finally {
backStub.restore();
}
});
});

View File

@@ -0,0 +1,149 @@
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('.sidebar-header h2').includesText('Results');
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('.sidebar-header h2').includesText('Nearby');
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');
});
});

View 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');
});
});

View 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();
});
});

View 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']);
});
});

View File

@@ -1,5 +1,6 @@
import { module, test } from 'qunit';
import { setupTest } from 'marco/tests/helpers';
import Service from '@ember/service';
module('Unit | Route | place', function (hooks) {
setupTest(hooks);
@@ -8,4 +9,120 @@ module('Unit | Route | place', function (hooks) {
let route = this.owner.lookup('route:place');
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');
});
});

View 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],
]);
});
});

View 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
});
});

View File

@@ -0,0 +1,104 @@
import { module, test } from 'qunit';
import { setupTest } from 'marco/tests/helpers';
import { getLocalizedName, getPlaceType } from 'marco/utils/osm';
module('Unit | Utility | osm', function (hooks) {
setupTest(hooks);
test('getLocalizedName returns default name if tags are missing', function (assert) {
const result = getLocalizedName(null);
assert.strictEqual(result, 'Untitled Place');
});
test('getLocalizedName returns name tag', function (assert) {
const tags = { name: 'Foo' };
const result = getLocalizedName(tags);
assert.strictEqual(result, 'Foo');
});
test('getLocalizedName falls back to name:en if name is missing', function (assert) {
const tags = { 'name:en': 'English Name' };
const result = getLocalizedName(tags);
assert.strictEqual(result, 'English Name');
});
test('getLocalizedName returns local name (name tag) if no preferred language match found', function (assert) {
// Assuming the test environment doesn't have 'fr' as a preferred language
const tags = { name: 'Local Name', 'name:fr': 'French Name' };
// Temporarily mock navigator to ensure no match
const originalLanguages = navigator.languages;
const originalLanguage = navigator.language;
Object.defineProperty(navigator, 'languages', {
value: ['es'],
configurable: true,
});
Object.defineProperty(navigator, 'language', {
value: 'es',
configurable: true,
});
try {
const result = getLocalizedName(tags);
assert.strictEqual(result, 'Local Name');
} finally {
// Restore
Object.defineProperty(navigator, 'languages', {
value: originalLanguages,
configurable: true,
});
Object.defineProperty(navigator, 'language', {
value: originalLanguage,
configurable: true,
});
}
});
test('getLocalizedName matches user preferred language', function (assert) {
const tags = {
name: 'Standard Name',
'name:de': 'Deutscher Name',
'name:fr': 'Nom Français',
};
const originalLanguages = navigator.languages;
Object.defineProperty(navigator, 'languages', {
value: ['de', 'en'],
configurable: true,
});
try {
const result = getLocalizedName(tags);
assert.strictEqual(result, 'Deutscher Name');
} finally {
Object.defineProperty(navigator, 'languages', {
value: originalLanguages,
configurable: true,
});
}
});
test('getPlaceType returns value for normal tags', function (assert) {
const tags = { amenity: 'restaurant' };
const result = getPlaceType(tags);
assert.strictEqual(result, 'Restaurant');
});
test('getPlaceType returns key name if value is "yes"', function (assert) {
const tags = { building: 'yes' };
const result = getPlaceType(tags);
assert.strictEqual(result, 'Building');
});
test('getPlaceType prioritizes order (amenity > shop > building)', function (assert) {
// If something is both a shop and a building, it should be a shop
const tags = { building: 'yes', shop: 'supermarket' };
const result = getPlaceType(tags);
assert.strictEqual(result, 'Supermarket');
});
test('getPlaceType returns null if no known type found', function (assert) {
const tags = { foo: 'bar' };
const result = getPlaceType(tags);
assert.strictEqual(result, null);
});
});