Compare commits
21 Commits
v1.15.4
...
feature/po
| Author | SHA1 | Date | |
|---|---|---|---|
|
9183e3c366
|
|||
|
7e98b6796c
|
|||
|
8e9beb16de
|
|||
|
b083c1d001
|
|||
|
4008a8c883
|
|||
|
eb7cff7ff5
|
|||
|
db6478e353
|
|||
|
b39d92b7c4
|
|||
|
aa99e5d766
|
|||
|
5fd4ebe184
|
|||
|
f2a2d910a0
|
|||
|
6b37508f66
|
|||
|
8106009677
|
|||
|
07489c43a4
|
|||
|
a4e375cb51
|
|||
|
b680769eac
|
|||
|
4a609c8388
|
|||
|
cfcaaea3ec
|
|||
|
2f440d4971
|
|||
|
1c6cbe6b0f
|
|||
|
bdd5db157c
|
@@ -1,156 +1,74 @@
|
||||
# Project Status: Marco
|
||||
|
||||
**Last Updated:** Tue Feb 24 2026
|
||||
**Last Updated:** Wed Mar 18 2026
|
||||
|
||||
## Project Context
|
||||
|
||||
We are building **Marco**, a decentralized maps application using **Ember.js** (Octane/Polaris edition with GJS/GLIMMER), **Vite**, and **OpenLayers**. The core feature is storing place bookmarks in **RemoteStorage.js**, using a custom module structure.
|
||||
We are building **Marco**, a decentralized maps application using **Ember.js** (Octane/Polaris), **Vite**, and **OpenLayers**. The core feature is storing place bookmarks in **RemoteStorage.js**.
|
||||
|
||||
## What We Have Done
|
||||
|
||||
### 1. Map Integration
|
||||
|
||||
- Set up OpenLayers in `app/components/map.gjs` (class-based component).
|
||||
- Switched tiles to **OpenFreeMap Liberty** style (supports vector POIs).
|
||||
- Implemented a hybrid click handler:
|
||||
- Detects clicks on visual vector tiles.
|
||||
- Falls back to fetching authoritative data from an **Overpass API** service.
|
||||
- **Logic Upgrade:** Map intelligently detects if _any_ sidebar/pane is open and handles outside clicks to close them instead of initiating new searches.
|
||||
- **Optimization:** Added **10px hit tolerance** for easier tapping on mobile devices.
|
||||
- **Visuals:** Increased bookmark marker size (Radius 9px) and added a subtle drop shadow.
|
||||
- **Feedback:** Implemented a "pulse" animation (via OpenLayers Overlay) at the click location to visualize the search radius (30m/50m).
|
||||
- **Mobile UX:**
|
||||
- **Touch:** Disabled browser tap highlights (`-webkit-tap-highlight-color: transparent`) to prevent blue flashing on Android.
|
||||
- **Scroll:** Disabled "pull-to-refresh" (`overscroll-behavior: none`) on the body to prevent accidental reloads while keeping the sidebar scrollable (`contain`).
|
||||
- **Auto-Pan:** On mobile screens, if a selected pin is obscured by the bottom sheet, the map automatically pans to center the pin in the visible top half of the screen.
|
||||
- **Controls:** Fixed positioning of "Locate" and "Rotate" buttons on mobile by correcting CSS `inset` syntax.
|
||||
- **iOS Polish:**
|
||||
- Prevented input auto-zoom by ensuring `.form-control` font size is `1rem` (16px).
|
||||
- Added `-webkit-text-size-adjust: 100%` to prevent text inflation on rotation.
|
||||
- Set base `body` font size to `16px`.
|
||||
- **Geolocation ("Locate Me"):**
|
||||
- Implemented a "Locate Me" button with robust tracking logic.
|
||||
- **Dynamic Zoom:** Automatically zooms to a level where the accuracy circle covers ~10% of the map (fallback logic handles missing accuracy data).
|
||||
- **Smart Pulse:** Displays a pulsing blue circle during the search phase.
|
||||
- **Auto-Stop:** Pulse and tracking automatically stop when high accuracy (≤20m) is achieved or after a 10s timeout.
|
||||
- **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.
|
||||
- **Vector Tiles:** Using **OpenFreeMap Liberty** style with a hybrid click handler (Visual Tiles + Overpass API fallback).
|
||||
- **Smart Interaction:**
|
||||
- **Hit Tolerance:** 10px buffer for easier mobile tapping.
|
||||
- **Auto-Pan:** Selected pins automatically center in the visible area (respecting bottom sheets/sidebars).
|
||||
- **Smart Zoom:** `zoomToBbox` fits complex geometries (ways/relations) with dynamic padding, only zooming out to fit.
|
||||
- **Visuals:** Custom "Red Pin" overlay with drop animation. Selected OSM ways/relations show distinct blue outlines.
|
||||
- **Geolocation:** Robust "Locate Me" with dynamic zoom and accuracy visualization.
|
||||
|
||||
### 2. RemoteStorage Module (`@remotestorage/module-places`)
|
||||
|
||||
- Created a custom TypeScript module in `vendor/remotestorage-module-places/`.
|
||||
- **Schema:** `place` object containing `id` (ULID), `title`, `lat`, `lon`, `geohash`, `osmId`, `url`, etc.
|
||||
- **Storage Path:** Nested `<2-char>/<2-char>/<id>` (based on geohash) for scalability.
|
||||
- **API:**
|
||||
- `getPlaces(prefixes?)`: efficient partial loading of specific sectors (or full recursive scan if no prefixes provided).
|
||||
- Uses `getListing` for directory traversal and `getAll` for object retrieval.
|
||||
- configured with `maxAge: false` to ensure data freshness.
|
||||
- **Dependencies:** Uses `ulid` and `latlon-geohash` internally.
|
||||
- **Custom Module:** Handles `place` objects with Geohash-based partitioning (`<2-char>/<2-char>/<id>`).
|
||||
- **Optimization:** Supports efficient spatial querying via prefix loading.
|
||||
- **Lists Support:** Manages collection-based organization (e.g., "To Visit", "Favorites").
|
||||
|
||||
### 3. App Infrastructure & Build
|
||||
### 3. App Infrastructure
|
||||
|
||||
- **Services:**
|
||||
- `storage.js`: Initializes RemoteStorage, claims access, enables caching, and sets up the widget. Consumes the new `getPlaces` API.
|
||||
- **Optimization:** Implemented **Debounced Reload** (200ms) for bookmark updates to handle rapid change events efficiently.
|
||||
- **Optimization:** Correctly handles deletion/updates by clearing stale data for reloaded geohash sectors.
|
||||
- `osm.js`: Fetches nearby POIs from Overpass API.
|
||||
- **Configurable:** Now supports dynamic API endpoints via `SettingsService`.
|
||||
- **Reliability:** Implemented `fetchWithRetry` to handle HTTP 504/502/503 timeouts and 429 rate limits, in addition to network errors.
|
||||
- **Caching:** Implemented in-memory cache for repeated `getNearbyPois` requests (same lat/lon/radius) to enable instant "Back" navigation.
|
||||
- `settings.js`: Manages user preferences (currently Overpass API provider) persisted to `localStorage`.
|
||||
- `storage.js`: Manages RemoteStorage, caching, and the new **Lists** feature (`to-go`, `to-do`).
|
||||
- `osm.js`: Fetches/caches POIs from Overpass API (configurable endpoints).
|
||||
- `settings.js`: Persists user preferences (e.g., API provider).
|
||||
- **UI Components:**
|
||||
- `places-sidebar.gjs`: Displays a list of nearby POIs.
|
||||
- **Layout:** Responsive design that transforms into a **Bottom Sheet** (50% height) on mobile screens (`<=768px`) with rounded corners and upward shadow.
|
||||
- `place-details.gjs`: Dedicated component for displaying rich place information.
|
||||
- **Features:** Icons (via `feather-icons`), Address, Phone, Website, Opening Hours, Cuisine, Wikipedia.
|
||||
- **Layout:** Polished UI with distinct sections for Actions and Meta info.
|
||||
- `app-header.gjs`: Transparent header with "Menu" button (Settings) and User Avatar (Login).
|
||||
- `settings-pane.gjs`: Sidebar component for app info ("About" section) and settings.
|
||||
- **Features:** Dropdown to select Overpass API provider (bke.ro, overpass-api.de, private.coffee).
|
||||
- **Mobile:** Renders as a 2/3 height bottom sheet on mobile.
|
||||
- **Z-Index:** Configured to overlay the Places sidebar correctly (`z-index: 3200`).
|
||||
- **Geo Utils:**
|
||||
- `app/utils/geo.js`: Haversine distance calculations.
|
||||
- `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`.
|
||||
- **Ember CLI:** Added as dev dependency to support generator commands.
|
||||
- **License:** Added AGPLv3 license.
|
||||
- **Responsive Layout:** Sidebar transforms into a Bottom Sheet on mobile.
|
||||
- **Place Details:** Rich info (Address, Socials, Opening Hours) with distinct "Actions" and "Meta" sections.
|
||||
- **App Menu:** Comprehensive settings and about section, implemented as a secondary sidebar.
|
||||
- **CI/CD:** Gitea Actions for automated testing and release drafting.
|
||||
|
||||
### 4. Routing & Architecture (Refactored)
|
||||
### 4. Routing & Architecture
|
||||
|
||||
- **URL-Driven Architecture:** Moved from service-based state to proper route-based state management.
|
||||
- `/search?lat=...&lon=...&q=...`: Displays search results list.
|
||||
- `/place/:place_id`: Displays details for a specific place (OSM POI or Bookmark).
|
||||
- **Heuristic Navigation:** The `search` route implements "visual click matching" logic. If a search yields a direct match (exact name or very close proximity), it automatically redirects to the `/place/` route, skipping the list view.
|
||||
- **Back Button Support:** Browser history works correctly. Navigating "Back" from a place returns to the cached search results instantly without network requests.
|
||||
- **Explicit URLs:** Routes support specific OSM entities via `/place/osm:node:<id>` and `/place/osm:way:<id>`, distinguishing them from local bookmarks (ULIDs).
|
||||
- **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.
|
||||
- **URL-Driven:** `/search` (list) and `/place/:id` (details) routes.
|
||||
- **Smart Navigation:**
|
||||
- Direct hits redirect to details.
|
||||
- Search results automatically resolve to existing **Bookmarks**.
|
||||
- "Back" navigation returns to cached search results instantly.
|
||||
|
||||
### 5. Creation & Editing Workflow
|
||||
### 5. Features
|
||||
|
||||
- **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.
|
||||
- **Search:** Typo-tolerant **Photon API** integration with location bias and debounce.
|
||||
- **Creation & Editing:**
|
||||
- "Crosshair" mode for precise location picking.
|
||||
- Edit Title/Description for saved places.
|
||||
- **Lists:** Users can add places to default lists ("To Go", "To Do") directly from the details view.
|
||||
- **Socials:** Place details now include Email, Facebook, and Instagram links.
|
||||
- **Data Sync:** Auto-refreshes OSM data (coords/tags) for saved places on view, preserving custom titles.
|
||||
|
||||
## Current State
|
||||
|
||||
- **Repo:** The app runs via `pnpm start`.
|
||||
- **Repo:** Runs via `pnpm start`.
|
||||
- **Workflow:**
|
||||
1. User pans map -> `moveend` triggers `storage.loadPlacesInBounds`.
|
||||
2. User clicks map -> Route transition to `/search` -> "Pulse" animation -> hybrid hit detection (Visual Tile vs Overpass).
|
||||
3. **Navigation:**
|
||||
- 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. **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.
|
||||
1. **Explore:** Pan/Zoom loads bookmarks from RemoteStorage.
|
||||
2. **Search:** Query via Photon -> List or Direct Result.
|
||||
3. **View:** Details pane (Sidebar/Bottom Sheet) shows rich info + social links.
|
||||
4. **Action:**
|
||||
- **Save:** Persist to RemoteStorage.
|
||||
- **Organize:** Add to "To Go" / "To Do" lists.
|
||||
- **Edit:** Custom Title/Description.
|
||||
5. **Sync:** Background check updates OSM data if changed.
|
||||
|
||||
## Files Currently in Focus
|
||||
## Next Steps
|
||||
|
||||
- `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 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).
|
||||
|
||||
## Technical Constraints
|
||||
|
||||
- **Template Style:** Strict Mode GJS (`<template>`).
|
||||
- **Package Manager:** `pnpm` for the main app, `npm` for the vendor module.
|
||||
- **Visuals:** No Tailwind/Bootstrap; using custom CSS in `app/styles/app.css`.
|
||||
1. **Testing:** Add automated tests for the new Lists logic and Geohash coverage.
|
||||
2. **Performance:** Monitor with large datasets.
|
||||
3. **Refinement:** Polish list UI and interactions.
|
||||
|
||||
@@ -6,10 +6,16 @@ import { on } from '@ember/modifier';
|
||||
import Icon from '#components/icon';
|
||||
import UserMenu from '#components/user-menu';
|
||||
import SearchBox from '#components/search-box';
|
||||
import CategoryChips from '#components/category-chips';
|
||||
|
||||
export default class AppHeaderComponent extends Component {
|
||||
@service storage;
|
||||
@tracked isUserMenuOpen = false;
|
||||
@tracked searchQuery = '';
|
||||
|
||||
get hasQuery() {
|
||||
return !!this.searchQuery;
|
||||
}
|
||||
|
||||
@action
|
||||
toggleUserMenu() {
|
||||
@@ -21,10 +27,30 @@ export default class AppHeaderComponent extends Component {
|
||||
this.isUserMenuOpen = false;
|
||||
}
|
||||
|
||||
@action
|
||||
handleQueryChange(query) {
|
||||
this.searchQuery = query;
|
||||
}
|
||||
|
||||
@action
|
||||
handleChipSelect(category) {
|
||||
this.searchQuery = category.label;
|
||||
// The existing logic in CategoryChips triggers the route transition.
|
||||
// This update simply fills the search box.
|
||||
}
|
||||
|
||||
<template>
|
||||
<header class="app-header">
|
||||
<div class="header-left">
|
||||
<SearchBox @onToggleMenu={{@onToggleMenu}} />
|
||||
<SearchBox
|
||||
@query={{this.searchQuery}}
|
||||
@onToggleMenu={{@onToggleMenu}}
|
||||
@onQueryChange={{this.handleQueryChange}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="header-center {{if this.hasQuery 'searching'}}">
|
||||
<CategoryChips @onSelect={{this.handleChipSelect}} />
|
||||
</div>
|
||||
|
||||
<div class="header-right">
|
||||
|
||||
@@ -111,6 +111,22 @@ import Icon from '#components/icon';
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<a href="https://pinhead.ink/" target="_blank" rel="noopener">
|
||||
Pinhead Icons
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<a
|
||||
href="https://github.com/waysidemapping/pinhead?tab=readme-ov-file#where-the-icons-are-from"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>
|
||||
Various
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
52
app/components/category-chips.gjs
Normal file
52
app/components/category-chips.gjs
Normal file
@@ -0,0 +1,52 @@
|
||||
import Component from '@glimmer/component';
|
||||
import { service } from '@ember/service';
|
||||
import { action } from '@ember/object';
|
||||
import { on } from '@ember/modifier';
|
||||
import { fn } from '@ember/helper';
|
||||
import Icon from '#components/icon';
|
||||
import { POI_CATEGORIES } from '../utils/poi-categories';
|
||||
|
||||
export default class CategoryChipsComponent extends Component {
|
||||
@service router;
|
||||
@service mapUi;
|
||||
|
||||
get categories() {
|
||||
return POI_CATEGORIES;
|
||||
}
|
||||
|
||||
@action
|
||||
searchCategory(category) {
|
||||
// If passed an onSelect action, call it (e.g. to clear search box)
|
||||
if (this.args.onSelect) {
|
||||
this.args.onSelect(category);
|
||||
}
|
||||
|
||||
let queryParams = { category: category.id, q: null };
|
||||
|
||||
if (this.mapUi.currentCenter) {
|
||||
const { lat, lon } = this.mapUi.currentCenter;
|
||||
queryParams.lat = parseFloat(lat).toFixed(4);
|
||||
queryParams.lon = parseFloat(lon).toFixed(4);
|
||||
}
|
||||
|
||||
this.router.transitionTo('search', { queryParams });
|
||||
}
|
||||
|
||||
<template>
|
||||
<div class="category-chips-scroll">
|
||||
<div class="category-chips-container">
|
||||
{{#each this.categories as |category|}}
|
||||
<button
|
||||
type="button"
|
||||
class="category-chip"
|
||||
{{on "click" (fn this.searchCategory category)}}
|
||||
aria-label={{category.label}}
|
||||
>
|
||||
<Icon @name={{category.icon}} @size={{16}} />
|
||||
<span>{{category.label}}</span>
|
||||
</button>
|
||||
{{/each}}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import Component from '@glimmer/component';
|
||||
import { htmlSafe } from '@ember/template';
|
||||
import { getIcon } from '../utils/icons';
|
||||
import { getIcon, isIconFilled } from '../utils/icons';
|
||||
|
||||
export default class IconComponent extends Component {
|
||||
get svg() {
|
||||
@@ -25,10 +25,14 @@ export default class IconComponent extends Component {
|
||||
return this.args.title || '';
|
||||
}
|
||||
|
||||
get isFilled() {
|
||||
return this.args.filled || isIconFilled(this.args.name);
|
||||
}
|
||||
|
||||
<template>
|
||||
{{#if this.svg}}
|
||||
<span
|
||||
class="icon {{if @filled 'icon-filled'}}"
|
||||
class="icon {{if this.isFilled 'icon-filled'}}"
|
||||
style={{this.style}}
|
||||
title={{this.title}}
|
||||
>
|
||||
|
||||
@@ -914,6 +914,7 @@ export default class MapComponent extends Component {
|
||||
const [maxLon, maxLat] = toLonLat([extent[2], extent[3]]);
|
||||
|
||||
const bbox = { minLat, minLon, maxLat, maxLon };
|
||||
this.mapUi.updateBounds(bbox);
|
||||
await this.storage.loadPlacesInBounds(bbox);
|
||||
this.loadBookmarks(this.storage.placesInView);
|
||||
|
||||
|
||||
@@ -305,6 +305,7 @@ export default class PlaceDetails extends Component {
|
||||
type="button"
|
||||
class="btn btn-outline"
|
||||
title="Edit"
|
||||
disabled={{this.isEditing}}
|
||||
{{on "click" this.startEditing}}
|
||||
>
|
||||
<Icon @name="edit" @color="var(--link-color)" />
|
||||
@@ -316,9 +317,11 @@ export default class PlaceDetails extends Component {
|
||||
<div class="meta-info">
|
||||
|
||||
{{#if this.cuisine}}
|
||||
<p class="cuisine-info">
|
||||
<strong>Cuisine:</strong>
|
||||
{{this.cuisine}}
|
||||
<p class="content-with-icon">
|
||||
<Icon @name="fork-and-knife" @title="Cuisine" />
|
||||
<span>
|
||||
{{this.cuisine}}
|
||||
</span>
|
||||
</p>
|
||||
{{/if}}
|
||||
|
||||
@@ -390,7 +393,7 @@ export default class PlaceDetails extends Component {
|
||||
|
||||
{{#if this.wikipedia}}
|
||||
<p class="content-with-icon">
|
||||
<Icon @name="wikipedia" @title="Wikipedia" @filled={{true}} />
|
||||
<Icon @name="wikipedia" @title="Wikipedia" />
|
||||
<span>
|
||||
<a
|
||||
href="https://wikipedia.org/wiki/{{this.wikipedia}}"
|
||||
|
||||
@@ -146,7 +146,7 @@ export default class PlacesSidebar extends Component {
|
||||
|
||||
get isNearbySearch() {
|
||||
const qp = this.router.currentRoute.queryParams;
|
||||
return !qp.q && qp.lat && qp.lon;
|
||||
return !qp.q && !qp.category && qp.lat && qp.lon;
|
||||
}
|
||||
|
||||
<template>
|
||||
|
||||
@@ -7,6 +7,7 @@ import { fn } from '@ember/helper';
|
||||
import { task, timeout } from 'ember-concurrency';
|
||||
import Icon from '#components/icon';
|
||||
import humanizeOsmTag from '../helpers/humanize-osm-tag';
|
||||
import { POI_CATEGORIES } from '../utils/poi-categories';
|
||||
import eq from 'ember-truth-helpers/helpers/eq';
|
||||
|
||||
export default class SearchBoxComponent extends Component {
|
||||
@@ -15,30 +16,45 @@ export default class SearchBoxComponent extends Component {
|
||||
@service mapUi;
|
||||
@service map; // Assuming we might need map context, but mostly we use router
|
||||
|
||||
@tracked query = '';
|
||||
@tracked _internalQuery = '';
|
||||
@tracked results = [];
|
||||
@tracked isFocused = false;
|
||||
@tracked isLoading = false;
|
||||
|
||||
get query() {
|
||||
return this.args.query ?? this._internalQuery;
|
||||
}
|
||||
|
||||
set query(value) {
|
||||
this._internalQuery = value;
|
||||
}
|
||||
|
||||
get showPopover() {
|
||||
return this.isFocused && this.results.length > 0;
|
||||
}
|
||||
|
||||
@action
|
||||
handleInput(event) {
|
||||
this.query = event.target.value;
|
||||
if (this.query.length < 2) {
|
||||
const value = event.target.value;
|
||||
this.query = value;
|
||||
if (this.args.onQueryChange) {
|
||||
this.args.onQueryChange(value);
|
||||
}
|
||||
|
||||
if (value.length < 2) {
|
||||
this.results = [];
|
||||
return;
|
||||
}
|
||||
|
||||
this.searchTask.perform();
|
||||
this.searchTask.perform(value);
|
||||
}
|
||||
|
||||
searchTask = task({ restartable: true }, async () => {
|
||||
searchTask = task({ restartable: true }, async (term) => {
|
||||
await timeout(300);
|
||||
|
||||
if (this.query.length < 2) return;
|
||||
const query = typeof term === 'string' ? term : this.query;
|
||||
|
||||
if (query.length < 2) return;
|
||||
|
||||
this.isLoading = true;
|
||||
try {
|
||||
@@ -47,8 +63,20 @@ export default class SearchBoxComponent extends Component {
|
||||
if (this.mapUi.currentCenter) {
|
||||
({ lat, lon } = this.mapUi.currentCenter);
|
||||
}
|
||||
const results = await this.photon.search(this.query, lat, lon);
|
||||
this.results = results;
|
||||
|
||||
// Filter categories
|
||||
const q = query.toLowerCase();
|
||||
const categoryMatches = POI_CATEGORIES.filter((c) =>
|
||||
c.label.toLowerCase().includes(q)
|
||||
).map((c) => ({
|
||||
source: 'category',
|
||||
title: c.label,
|
||||
id: c.id,
|
||||
icon: 'search',
|
||||
}));
|
||||
|
||||
const results = await this.photon.search(query, lat, lon);
|
||||
this.results = [...categoryMatches, ...results];
|
||||
} catch (e) {
|
||||
console.error('Search failed', e);
|
||||
this.results = [];
|
||||
@@ -80,7 +108,7 @@ export default class SearchBoxComponent extends Component {
|
||||
event.preventDefault();
|
||||
if (!this.query) return;
|
||||
|
||||
let queryParams = { q: this.query, selected: null };
|
||||
let queryParams = { q: this.query, selected: null, category: null };
|
||||
|
||||
if (this.mapUi.currentCenter) {
|
||||
const { lat, lon } = this.mapUi.currentCenter;
|
||||
@@ -94,7 +122,37 @@ export default class SearchBoxComponent extends Component {
|
||||
|
||||
@action
|
||||
selectResult(place) {
|
||||
if (place.source === 'category') {
|
||||
this.query = place.title;
|
||||
if (this.args.onQueryChange) {
|
||||
this.args.onQueryChange(place.title);
|
||||
}
|
||||
this.results = [];
|
||||
|
||||
let lat = null,
|
||||
lon = null;
|
||||
if (this.mapUi.currentCenter) {
|
||||
({ lat, lon } = this.mapUi.currentCenter);
|
||||
lat = lat?.toString();
|
||||
lon = lon?.toString();
|
||||
}
|
||||
|
||||
this.router.transitionTo('search', {
|
||||
queryParams: {
|
||||
q: place.title,
|
||||
category: place.id,
|
||||
selected: null,
|
||||
lat: lat,
|
||||
lon: lon,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
this.query = place.title;
|
||||
if (this.args.onQueryChange) {
|
||||
this.args.onQueryChange(place.title);
|
||||
}
|
||||
this.results = []; // Hide popover
|
||||
|
||||
// If it has an OSM ID, go to place details
|
||||
@@ -112,6 +170,7 @@ export default class SearchBoxComponent extends Component {
|
||||
lat: place.lat,
|
||||
lon: place.lon,
|
||||
selected: null,
|
||||
category: null,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -121,8 +180,10 @@ export default class SearchBoxComponent extends Component {
|
||||
clear() {
|
||||
this.query = '';
|
||||
this.results = [];
|
||||
this.router.transitionTo('index'); // Or stay on current page?
|
||||
// Usually clear just clears the input.
|
||||
if (this.args.onQueryChange) {
|
||||
this.args.onQueryChange('');
|
||||
}
|
||||
this.router.transitionTo('index');
|
||||
}
|
||||
|
||||
<template>
|
||||
@@ -176,7 +237,11 @@ export default class SearchBoxComponent extends Component {
|
||||
{{on "click" (fn this.selectResult result)}}
|
||||
>
|
||||
<div class="result-icon">
|
||||
<Icon @name="map-pin" @size={{16}} @color="#666" />
|
||||
<Icon
|
||||
@name={{if result.icon result.icon "map-pin"}}
|
||||
@size={{16}}
|
||||
@color="#666"
|
||||
/>
|
||||
</div>
|
||||
<div class="result-info">
|
||||
<span class="result-title">{{result.title}}</span>
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import Controller from '@ember/controller';
|
||||
|
||||
export default class SearchController extends Controller {
|
||||
queryParams = ['lat', 'lon', 'q', 'selected'];
|
||||
queryParams = ['lat', 'lon', 'q', 'selected', 'category'];
|
||||
|
||||
lat = null;
|
||||
lon = null;
|
||||
q = null;
|
||||
selected = null;
|
||||
category = null;
|
||||
}
|
||||
|
||||
@@ -101,6 +101,23 @@ export default class PlaceRoute extends Route {
|
||||
return null;
|
||||
}
|
||||
|
||||
setupController(controller, model) {
|
||||
super.setupController(controller, model);
|
||||
this.checkUpdates(model);
|
||||
}
|
||||
|
||||
async checkUpdates(place) {
|
||||
// Only check for updates if it's a saved place (has ID) and is an OSM object
|
||||
if (place && place.id && place.osmId && place.osmType) {
|
||||
const updatedPlace = await this.storage.refreshPlace(place);
|
||||
if (updatedPlace) {
|
||||
// If an update occurred, refresh the map UI selection without moving the camera
|
||||
// This ensures the sidebar shows the new data
|
||||
this.mapUi.selectPlace(updatedPlace, { preventZoom: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
serialize(model) {
|
||||
// If the model is a saved bookmark, use its ID
|
||||
if (model.id) {
|
||||
|
||||
@@ -15,6 +15,7 @@ export default class SearchRoute extends Route {
|
||||
lon: { refreshModel: true },
|
||||
q: { refreshModel: true },
|
||||
selected: { refreshModel: true },
|
||||
category: { refreshModel: true },
|
||||
};
|
||||
|
||||
async model(params) {
|
||||
@@ -22,8 +23,37 @@ export default class SearchRoute extends Route {
|
||||
const lon = params.lon ? parseFloat(params.lon) : null;
|
||||
let pois = [];
|
||||
|
||||
// Case 0: Category Search (category parameter present)
|
||||
if (params.category && lat && lon) {
|
||||
// We need bounds. If we have active map state, use it.
|
||||
let bounds = this.mapUi.currentBounds;
|
||||
|
||||
// If we don't have bounds (direct URL visit), estimate them from lat/lon/zoom(16)
|
||||
// or just use a fixed box around the center.
|
||||
if (!bounds) {
|
||||
// Approximate 0.01 degrees ~ 1km at equator. A viewport is roughly 0.02x0.01 at zoom 16?
|
||||
// Let's take a safe box of ~1km radius.
|
||||
const delta = 0.01;
|
||||
bounds = {
|
||||
minLat: lat - delta,
|
||||
maxLat: lat + delta,
|
||||
minLon: lon - delta,
|
||||
maxLon: lon + delta,
|
||||
};
|
||||
}
|
||||
|
||||
pois = await this.osm.getCategoryPois(bounds, params.category, lat, lon);
|
||||
|
||||
// Sort by distance from center
|
||||
pois = pois
|
||||
.map((p) => ({
|
||||
...p,
|
||||
_distance: getDistance(lat, lon, p.lat, p.lon),
|
||||
}))
|
||||
.sort((a, b) => a._distance - b._distance);
|
||||
}
|
||||
// Case 1: Text Search (q parameter present)
|
||||
if (params.q) {
|
||||
else if (params.q) {
|
||||
// Search with Photon (using lat/lon for bias if available)
|
||||
pois = await this.photon.search(params.q, lat, lon);
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ export default class MapUiService extends Service {
|
||||
@tracked creationCoordinates = null;
|
||||
@tracked returnToSearch = false;
|
||||
@tracked currentCenter = null;
|
||||
@tracked currentBounds = null;
|
||||
@tracked searchBoxHasFocus = false;
|
||||
@tracked selectionOptions = {};
|
||||
@tracked preventNextZoom = false;
|
||||
@@ -54,4 +55,8 @@ export default class MapUiService extends Service {
|
||||
updateCenter(lat, lon) {
|
||||
this.currentCenter = { lat, lon };
|
||||
}
|
||||
|
||||
updateBounds(bounds) {
|
||||
this.currentBounds = bounds;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import Service, { service } from '@ember/service';
|
||||
import { getLocalizedName, getPlaceType } from '../utils/osm';
|
||||
import { getCategoryById } from '../utils/poi-categories';
|
||||
|
||||
export default class OsmService extends Service {
|
||||
@service settings;
|
||||
@@ -76,6 +77,70 @@ out center;
|
||||
}
|
||||
}
|
||||
|
||||
async getCategoryPois(bounds, categoryId, lat, lon) {
|
||||
const category = getCategoryById(categoryId);
|
||||
if (!category || !bounds) return [];
|
||||
|
||||
const queryKey = lat && lon ? `cat:${categoryId}:${lat}:${lon}` : null;
|
||||
|
||||
if (queryKey && this.lastQueryKey === queryKey && this.cachedResults) {
|
||||
console.debug('Returning cached category results for:', queryKey);
|
||||
return this.cachedResults;
|
||||
}
|
||||
|
||||
if (this.controller) {
|
||||
this.controller.abort();
|
||||
}
|
||||
this.controller = new AbortController();
|
||||
const signal = this.controller.signal;
|
||||
|
||||
const { minLat, minLon, maxLat, maxLon } = bounds;
|
||||
|
||||
// Build the query parts for each filter string and type
|
||||
const queryParts = [];
|
||||
|
||||
// Default types if not specified (legacy fallback)
|
||||
const types = category.types || ['node', 'way', 'relation'];
|
||||
|
||||
category.filter.forEach((filterString) => {
|
||||
types.forEach((type) => {
|
||||
// We ensure we only fetch named POIs to reduce noise
|
||||
queryParts.push(`${type}${filterString}[~"^name"~"."];`);
|
||||
});
|
||||
});
|
||||
|
||||
const query = `
|
||||
[out:json][timeout:25][bbox:${minLat},${minLon},${maxLat},${maxLon}];
|
||||
(
|
||||
${queryParts.join('\n ')}
|
||||
);
|
||||
out center;
|
||||
`.trim();
|
||||
|
||||
const url = `${this.settings.overpassApi}?data=${encodeURIComponent(query)}`;
|
||||
|
||||
try {
|
||||
const res = await this.fetchWithRetry(url, { signal });
|
||||
if (!res.ok) throw new Error('Overpass request failed');
|
||||
const data = await res.json();
|
||||
const results = data.elements.map(this.normalizePoi);
|
||||
|
||||
if (queryKey) {
|
||||
this.lastQueryKey = queryKey;
|
||||
this.cachedResults = results;
|
||||
}
|
||||
|
||||
return results;
|
||||
} catch (e) {
|
||||
if (e.name === 'AbortError') {
|
||||
console.debug('Category search aborted');
|
||||
return [];
|
||||
}
|
||||
console.error('Category search failed', e);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
normalizePoi(poi) {
|
||||
const tags = poi.tags || {};
|
||||
const type = getPlaceType(tags) || 'Point of Interest';
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import Service from '@ember/service';
|
||||
import Service, { service } from '@ember/service';
|
||||
import RemoteStorage from 'remotestoragejs';
|
||||
import Places from '@remotestorage/module-places';
|
||||
import Widget from 'remotestorage-widget';
|
||||
@@ -7,8 +7,10 @@ import { getGeohashPrefixesInBbox } from '../utils/geohash-coverage';
|
||||
import { action } from '@ember/object';
|
||||
import { debounceTask } from 'ember-lifeline';
|
||||
import Geohash from 'latlon-geohash';
|
||||
import { getLocalizedName } from '../utils/osm';
|
||||
|
||||
export default class StorageService extends Service {
|
||||
@service osm;
|
||||
rs;
|
||||
widget;
|
||||
@tracked placesInView = [];
|
||||
@@ -366,6 +368,82 @@ export default class StorageService extends Service {
|
||||
}
|
||||
}
|
||||
|
||||
async refreshPlace(place) {
|
||||
if (!place || !place.id || !place.osmId || !place.osmType) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
console.debug(`Checking for updates for ${place.title} (${place.osmId})`);
|
||||
const freshData = await this.osm.fetchOsmObject(
|
||||
place.osmId,
|
||||
place.osmType
|
||||
);
|
||||
|
||||
if (!freshData) {
|
||||
console.warn('Could not fetch fresh data for', place.osmId);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check for changes
|
||||
let hasChanges = false;
|
||||
const changes = {};
|
||||
|
||||
// 1. Check Coordinates (allow tiny drift < ~1m)
|
||||
const latDiff = Math.abs(place.lat - freshData.lat);
|
||||
const lonDiff = Math.abs(place.lon - freshData.lon);
|
||||
if (latDiff > 0.00001 || lonDiff > 0.00001) {
|
||||
hasChanges = true;
|
||||
changes.lat = freshData.lat;
|
||||
changes.lon = freshData.lon;
|
||||
}
|
||||
|
||||
// 2. Check Tags
|
||||
const oldTags = place.osmTags || {};
|
||||
const newTags = freshData.osmTags || {};
|
||||
const allKeys = new Set([
|
||||
...Object.keys(oldTags),
|
||||
...Object.keys(newTags),
|
||||
]);
|
||||
|
||||
for (const key of allKeys) {
|
||||
if (oldTags[key] !== newTags[key]) {
|
||||
hasChanges = true;
|
||||
changes.osmTags = newTags;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasChanges) {
|
||||
console.debug('No changes detected for', place.title);
|
||||
return null;
|
||||
}
|
||||
|
||||
console.debug('Changes detected:', changes);
|
||||
|
||||
// 3. Prepare Update
|
||||
const updatedPlace = {
|
||||
...place,
|
||||
...changes,
|
||||
};
|
||||
|
||||
// If the current title matches the old localized name, update it to the
|
||||
// new localized name. If the user renamed it (custom title), keep it.
|
||||
const oldDefaultName = getLocalizedName(oldTags);
|
||||
const newDefaultName = getLocalizedName(newTags);
|
||||
|
||||
if (place.title === oldDefaultName && oldDefaultName !== newDefaultName) {
|
||||
updatedPlace.title = newDefaultName;
|
||||
}
|
||||
|
||||
// 4. Save
|
||||
return await this.updatePlace(updatedPlace);
|
||||
} catch (e) {
|
||||
console.error('Failed to refresh place:', e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@action
|
||||
connect() {
|
||||
this.isWidgetOpen = true;
|
||||
|
||||
@@ -70,27 +70,96 @@ body {
|
||||
right: 0;
|
||||
height: 60px;
|
||||
padding: 0.5rem 1rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
z-index: 3000; /* Above sidebar (2000) and map */
|
||||
pointer-events: none; /* Let clicks pass through to map where transparent */
|
||||
|
||||
/* Layout */
|
||||
display: grid;
|
||||
|
||||
/* Desktop: 1fr auto 1fr ensures the center element is absolutely centered */
|
||||
grid-template-columns: 1fr auto 1fr;
|
||||
grid-template-areas: 'search chips user';
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
@media (width <= 768px) {
|
||||
.app-header {
|
||||
padding: 0 0.5rem;
|
||||
padding: 0.5rem 0.5rem 0;
|
||||
height: auto;
|
||||
grid-template-columns: 1fr auto;
|
||||
grid-template-areas:
|
||||
'search user'
|
||||
'chips chips';
|
||||
row-gap: 8px; /* Increased spacing */
|
||||
}
|
||||
}
|
||||
|
||||
.header-left,
|
||||
.header-right {
|
||||
pointer-events: auto; /* Re-enable clicks for buttons */
|
||||
.header-right,
|
||||
.header-center {
|
||||
pointer-events: auto; /* Re-enable clicks */
|
||||
}
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
grid-area: search;
|
||||
|
||||
/* Ensure it sits at the start of its grid area */
|
||||
justify-self: start;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@media (width > 768px) {
|
||||
.header-left {
|
||||
min-width: 300px;
|
||||
max-width: 400px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (width > 768px) {
|
||||
.header-left {
|
||||
/* Desktop: Ensure minimum width for search box so it's not squeezed */
|
||||
min-width: 300px;
|
||||
max-width: 350px;
|
||||
}
|
||||
}
|
||||
|
||||
.header-right {
|
||||
grid-area: user;
|
||||
justify-self: end;
|
||||
}
|
||||
|
||||
.header-center {
|
||||
grid-area: chips;
|
||||
|
||||
/* Desktop: Center the chips block in the available space */
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
min-width: 0; /* Allow shrinking */
|
||||
}
|
||||
|
||||
/* Adjust scroll container for desktop centering */
|
||||
@media (width > 768px) {
|
||||
.header-center .category-chips-scroll {
|
||||
width: auto;
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@media (width <= 768px) {
|
||||
/* No need to reset min-width/max-width since they are only set in media query above */
|
||||
.header-center {
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
justify-content: start;
|
||||
}
|
||||
|
||||
/* Hide chips on mobile when searching to save space */
|
||||
.header-center.searching {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.btn-press {
|
||||
@@ -603,6 +672,12 @@ abbr[title] {
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.btn-outline {
|
||||
background: transparent;
|
||||
color: #333;
|
||||
@@ -1184,3 +1259,57 @@ button.create-place {
|
||||
background: #eee;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
/* Category Chips */
|
||||
.category-chips-scroll {
|
||||
width: 100%;
|
||||
overflow-x: auto;
|
||||
|
||||
/* Add padding for shadows */
|
||||
padding: 4px 0;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
|
||||
/* Hide scrollbar but keep functionality */
|
||||
scrollbar-width: none; /* Firefox */
|
||||
-ms-overflow-style: none; /* IE/Edge */
|
||||
|
||||
/* Remove top margin as spacing is handled by grid/layout */
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.category-chips-scroll::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.category-chips-container {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
|
||||
/* Padding on sides so first/last chip isn't flush with screen edge */
|
||||
padding: 0 4px;
|
||||
width: max-content; /* Ensure it scrolls */
|
||||
}
|
||||
|
||||
.category-chip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 12px;
|
||||
background: white;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 16px; /* Pill shape */
|
||||
font-size: 0.9rem;
|
||||
color: #333;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
box-shadow: 0 1px 3px rgb(0 0 0 / 10%);
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.category-chip:hover {
|
||||
background: var(--hover-bg);
|
||||
}
|
||||
|
||||
.category-chip:active {
|
||||
background: #eee;
|
||||
}
|
||||
|
||||
@@ -27,14 +27,21 @@ 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 camera from '@waysidemapping/pinhead/dist/icons/camera.svg?raw';
|
||||
import cupAndSaucer from '@waysidemapping/pinhead/dist/icons/cup_and_saucer.svg?raw';
|
||||
import forkAndKnife from '@waysidemapping/pinhead/dist/icons/fork_and_knife.svg?raw';
|
||||
import personSleepingInBed from '@waysidemapping/pinhead/dist/icons/person_sleeping_in_bed.svg?raw';
|
||||
import shoppingBasket from '@waysidemapping/pinhead/dist/icons/shopping_basket.svg?raw';
|
||||
import wikipedia from '../icons/wikipedia.svg?raw';
|
||||
|
||||
const ICONS = {
|
||||
'arrow-left': arrowLeft,
|
||||
activity,
|
||||
bookmark,
|
||||
camera,
|
||||
'check-square': checkSquare,
|
||||
clock,
|
||||
'cup-and-saucer': cupAndSaucer,
|
||||
edit,
|
||||
facebook,
|
||||
gift,
|
||||
@@ -43,6 +50,7 @@ const ICONS = {
|
||||
home,
|
||||
info,
|
||||
instagram,
|
||||
'fork-and-knife': forkAndKnife,
|
||||
'log-in': logIn,
|
||||
'log-out': logOut,
|
||||
mail,
|
||||
@@ -50,11 +58,13 @@ const ICONS = {
|
||||
'map-pin': mapPin,
|
||||
menu,
|
||||
navigation,
|
||||
'person-sleeping-in-bed': personSleepingInBed,
|
||||
phone,
|
||||
plus,
|
||||
server,
|
||||
search,
|
||||
settings,
|
||||
'shopping-basket': shoppingBasket,
|
||||
target,
|
||||
user,
|
||||
wikipedia,
|
||||
@@ -62,6 +72,19 @@ const ICONS = {
|
||||
zap,
|
||||
};
|
||||
|
||||
const FILLED_ICONS = [
|
||||
'fork-and-knife',
|
||||
'wikipedia',
|
||||
'cup-and-saucer',
|
||||
'shopping-basket',
|
||||
'camera',
|
||||
'person-sleeping-in-bed',
|
||||
];
|
||||
|
||||
export function getIcon(name) {
|
||||
return ICONS[name];
|
||||
}
|
||||
|
||||
export function isIconFilled(name) {
|
||||
return FILLED_ICONS.includes(name);
|
||||
}
|
||||
|
||||
62
app/utils/poi-categories.js
Normal file
62
app/utils/poi-categories.js
Normal file
@@ -0,0 +1,62 @@
|
||||
// This configuration defines the "Quick Search" categories available in the UI.
|
||||
//
|
||||
// Structure:
|
||||
// - id: The URL slug used for routing (e.g. ?category=restaurants)
|
||||
// - label: The human-readable name displayed in the UI
|
||||
// - icon: The icon name (must be registered in app/utils/icons.js)
|
||||
// - filter: An array of Overpass QL query parts.
|
||||
// - Each string in the array is an independent query condition.
|
||||
// - Multiple strings act as an OR condition (union of results).
|
||||
|
||||
export const POI_CATEGORIES = [
|
||||
{
|
||||
id: 'restaurants',
|
||||
label: 'Restaurants',
|
||||
icon: 'fork-and-knife',
|
||||
filter: ['["amenity"~"^(restaurant|fast_food|food_court|pub|cafe)$"]'],
|
||||
types: ['node', 'way'],
|
||||
},
|
||||
{
|
||||
id: 'coffee',
|
||||
label: 'Coffee',
|
||||
icon: 'cup-and-saucer',
|
||||
filter: [
|
||||
'["amenity"~"^(cafe|ice_cream)$"]',
|
||||
'["shop"~"^(coffee|tea)$"]',
|
||||
'["cuisine"~"coffee_shop"]',
|
||||
],
|
||||
types: ['node', 'way'],
|
||||
},
|
||||
{
|
||||
id: 'groceries',
|
||||
label: 'Groceries',
|
||||
icon: 'shopping-basket',
|
||||
filter: [
|
||||
'["shop"~"^(supermarket|convenience|grocery|greengrocer|bakery|butcher|deli|farm|seafood)$"]',
|
||||
],
|
||||
types: ['node', 'way'],
|
||||
},
|
||||
{
|
||||
id: 'things-to-do',
|
||||
label: 'Things to do',
|
||||
icon: 'camera',
|
||||
filter: [
|
||||
'["tourism"~"^(museum|gallery|attraction|viewpoint|zoo|theme_park|aquarium|artwork)$"]',
|
||||
'["amenity"~"^(cinema|theatre|arts_centre|planetarium)$"]',
|
||||
'["leisure"~"^(sports_centre|stadium|water_park)$"]',
|
||||
'["historic"]',
|
||||
],
|
||||
types: ['node', 'way', 'relation'],
|
||||
},
|
||||
{
|
||||
id: 'accommodation',
|
||||
label: 'Hotels',
|
||||
icon: 'person-sleeping-in-bed',
|
||||
filter: ['["tourism"~"^(hotel|hostel|motel)$"]'],
|
||||
types: ['node', 'way', 'relation'],
|
||||
},
|
||||
];
|
||||
|
||||
export function getCategoryById(id) {
|
||||
return POI_CATEGORIES.find((c) => c.id === id);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "marco",
|
||||
"version": "1.15.4",
|
||||
"version": "1.16.0",
|
||||
"private": true,
|
||||
"description": "Unhosted maps app",
|
||||
"repository": {
|
||||
@@ -102,6 +102,7 @@
|
||||
"edition": "octane"
|
||||
},
|
||||
"dependencies": {
|
||||
"@waysidemapping/pinhead": "^15.17.0",
|
||||
"ember-concurrency": "^5.2.0",
|
||||
"ember-lifeline": "^7.0.0"
|
||||
}
|
||||
|
||||
8
pnpm-lock.yaml
generated
8
pnpm-lock.yaml
generated
@@ -8,6 +8,9 @@ importers:
|
||||
|
||||
.:
|
||||
dependencies:
|
||||
'@waysidemapping/pinhead':
|
||||
specifier: ^15.17.0
|
||||
version: 15.17.0
|
||||
ember-concurrency:
|
||||
specifier: ^5.2.0
|
||||
version: 5.2.0(@babel/core@7.28.6)
|
||||
@@ -1651,6 +1654,9 @@ packages:
|
||||
peerDependencies:
|
||||
'@warp-drive/core': 5.8.1
|
||||
|
||||
'@waysidemapping/pinhead@15.17.0':
|
||||
resolution: {integrity: sha512-XcL/0Ll+gkRIpXlO+skwd6USynA+mX3DNwqrWDMhgRmLP4DNRPTeaecK64BBxk1bB/F9Xi/9kgN6JA5zbdgejQ==}
|
||||
|
||||
'@xmldom/xmldom@0.8.11':
|
||||
resolution: {integrity: sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw==}
|
||||
engines: {node: '>=10.0.0'}
|
||||
@@ -7239,6 +7245,8 @@ snapshots:
|
||||
- '@glint/template'
|
||||
- supports-color
|
||||
|
||||
'@waysidemapping/pinhead@15.17.0': {}
|
||||
|
||||
'@xmldom/xmldom@0.8.11': {}
|
||||
|
||||
abbrev@1.1.1: {}
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
2
release/assets/main-C4F17h3W.js
Normal file
2
release/assets/main-C4F17h3W.js
Normal file
File diff suppressed because one or more lines are too long
1
release/assets/main-CKp1bFPU.css
Normal file
1
release/assets/main-CKp1bFPU.css
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -39,8 +39,8 @@
|
||||
<meta name="msapplication-TileColor" content="#F6E9A6">
|
||||
<meta name="msapplication-TileImage" content="/icons/icon-144.png">
|
||||
|
||||
<script type="module" crossorigin src="/assets/main-Bzf0iwOa.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/main-BOfcjRke.css">
|
||||
<script type="module" crossorigin src="/assets/main-C4F17h3W.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/main-CKp1bFPU.css">
|
||||
</head>
|
||||
<body>
|
||||
</body>
|
||||
|
||||
@@ -155,4 +155,67 @@ module('Acceptance | search', function (hooks) {
|
||||
assert.dom('.places-list li').exists({ count: 1 });
|
||||
assert.dom('.places-list li .place-name').hasText('My Secret Base');
|
||||
});
|
||||
|
||||
test('visiting /search with category parameter performs category search', async function (assert) {
|
||||
// Mock Osm Service
|
||||
class MockOsmService extends Service {
|
||||
async getCategoryPois(bounds, categoryId) {
|
||||
if (categoryId === 'coffee') {
|
||||
return [
|
||||
{
|
||||
title: 'Latte Art Cafe',
|
||||
lat: 52.52,
|
||||
lon: 13.405,
|
||||
osmId: '101',
|
||||
osmType: 'N',
|
||||
description: 'Best Coffee',
|
||||
},
|
||||
];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
}
|
||||
this.owner.register('service:osm', MockOsmService);
|
||||
|
||||
// Mock Storage Service (empty)
|
||||
class MockStorageService extends Service {
|
||||
savedPlaces = [];
|
||||
findPlaceById() {
|
||||
return null;
|
||||
}
|
||||
isPlaceSaved() {
|
||||
return false;
|
||||
}
|
||||
rs = { on: () => {} };
|
||||
placesInView = [];
|
||||
loadPlacesInBounds() {
|
||||
return Promise.resolve();
|
||||
}
|
||||
}
|
||||
this.owner.register('service:storage', MockStorageService);
|
||||
|
||||
// Mock Map Service (needed for bounds)
|
||||
class MockMapService extends Service {
|
||||
getBounds() {
|
||||
return {
|
||||
minLat: 52.5,
|
||||
minLon: 13.4,
|
||||
maxLat: 52.6,
|
||||
maxLon: 13.5,
|
||||
};
|
||||
}
|
||||
}
|
||||
this.owner.register('service:map', MockMapService);
|
||||
|
||||
await visit('/search?category=coffee&lat=52.52&lon=13.405');
|
||||
|
||||
assert.strictEqual(
|
||||
currentURL(),
|
||||
'/search?category=coffee&lat=52.52&lon=13.405'
|
||||
);
|
||||
assert.dom('.places-list li').exists({ count: 1 });
|
||||
assert.dom('.places-list li .place-name').hasText('Latte Art Cafe');
|
||||
// Ensure it shows "Results" not "Nearby"
|
||||
assert.dom('.sidebar-header h2').includesText('Results');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,13 +1,25 @@
|
||||
import { module, test } from 'qunit';
|
||||
import { setupRenderingTest } from 'marco/tests/helpers';
|
||||
import { render } from '@ember/test-helpers';
|
||||
import { render, fillIn } from '@ember/test-helpers';
|
||||
import AppHeader from 'marco/components/app-header';
|
||||
import Service from '@ember/service';
|
||||
|
||||
module('Integration | Component | app-header', function (hooks) {
|
||||
setupRenderingTest(hooks);
|
||||
|
||||
test('it renders the search box', async function (assert) {
|
||||
this.noop = () => {};
|
||||
|
||||
class MockPhotonService extends Service {}
|
||||
class MockRouterService extends Service {}
|
||||
class MockMapUiService extends Service {}
|
||||
class MockMapService extends Service {}
|
||||
|
||||
this.owner.register('service:photon', MockPhotonService);
|
||||
this.owner.register('service:router', MockRouterService);
|
||||
this.owner.register('service:map-ui', MockMapUiService);
|
||||
this.owner.register('service:map', MockMapService);
|
||||
|
||||
await render(
|
||||
<template><AppHeader @onToggleMenu={{this.noop}} /></template>
|
||||
);
|
||||
@@ -16,4 +28,39 @@ module('Integration | Component | app-header', function (hooks) {
|
||||
assert.dom('.search-box').exists('Search box is present in the header');
|
||||
assert.dom('.menu-btn-integrated').exists('Menu button is integrated');
|
||||
});
|
||||
|
||||
test('typing in search box toggles .searching class on header-center', async function (assert) {
|
||||
this.noop = () => {};
|
||||
|
||||
class MockPhotonService extends Service {
|
||||
search() {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
class MockRouterService extends Service {}
|
||||
class MockMapUiService extends Service {
|
||||
setSearchBoxFocus() {}
|
||||
currentCenter = null;
|
||||
}
|
||||
class MockMapService extends Service {}
|
||||
|
||||
this.owner.register('service:photon', MockPhotonService);
|
||||
this.owner.register('service:router', MockRouterService);
|
||||
this.owner.register('service:map-ui', MockMapUiService);
|
||||
this.owner.register('service:map', MockMapService);
|
||||
|
||||
await render(
|
||||
<template><AppHeader @onToggleMenu={{this.noop}} /></template>
|
||||
);
|
||||
|
||||
assert.dom('.header-center').doesNotHaveClass('searching');
|
||||
|
||||
await fillIn('.search-input', 'test');
|
||||
|
||||
assert.dom('.header-center').hasClass('searching');
|
||||
|
||||
await fillIn('.search-input', '');
|
||||
|
||||
assert.dom('.header-center').doesNotHaveClass('searching');
|
||||
});
|
||||
});
|
||||
|
||||
56
tests/integration/components/category-chips-test.gjs
Normal file
56
tests/integration/components/category-chips-test.gjs
Normal file
@@ -0,0 +1,56 @@
|
||||
import { module, test } from 'qunit';
|
||||
import { setupRenderingTest } from 'marco/tests/helpers';
|
||||
import { render, click } from '@ember/test-helpers';
|
||||
import CategoryChips from 'marco/components/category-chips';
|
||||
import Service from '@ember/service';
|
||||
import { POI_CATEGORIES } from 'marco/utils/poi-categories';
|
||||
|
||||
module('Integration | Component | category-chips', function (hooks) {
|
||||
setupRenderingTest(hooks);
|
||||
|
||||
test('it renders the correct number of chips', async function (assert) {
|
||||
class MockRouterService extends Service {}
|
||||
class MockMapUiService extends Service {}
|
||||
|
||||
this.owner.register('service:router', MockRouterService);
|
||||
this.owner.register('service:map-ui', MockMapUiService);
|
||||
|
||||
await render(<template><CategoryChips /></template>);
|
||||
|
||||
assert.dom('.category-chip').exists({ count: 5 });
|
||||
|
||||
// Check for some expected labels
|
||||
assert.dom(this.element).includesText('Restaurants');
|
||||
assert.dom(this.element).includesText('Coffee');
|
||||
});
|
||||
|
||||
test('clicking a chip triggers the @onSelect action', async function (assert) {
|
||||
let selectedCategory;
|
||||
this.handleSelect = (category) => {
|
||||
selectedCategory = category;
|
||||
};
|
||||
|
||||
class MockRouterService extends Service {
|
||||
transitionTo() {}
|
||||
}
|
||||
class MockMapUiService extends Service {}
|
||||
|
||||
this.owner.register('service:router', MockRouterService);
|
||||
this.owner.register('service:map-ui', MockMapUiService);
|
||||
|
||||
await render(
|
||||
<template><CategoryChips @onSelect={{this.handleSelect}} /></template>
|
||||
);
|
||||
|
||||
// Find the chip for "Coffee"
|
||||
const coffeeCategory = POI_CATEGORIES.find((c) => c.id === 'coffee');
|
||||
const chip = Array.from(
|
||||
this.element.querySelectorAll('.category-chip')
|
||||
).find((el) => el.textContent.includes(coffeeCategory.label));
|
||||
|
||||
await click(chip);
|
||||
|
||||
assert.strictEqual(selectedCategory.id, 'coffee');
|
||||
assert.strictEqual(selectedCategory.label, 'Coffee');
|
||||
});
|
||||
});
|
||||
@@ -100,7 +100,7 @@ module('Integration | Component | search-box', function (hooks) {
|
||||
.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));
|
||||
|
||||
assert.verifySteps([
|
||||
'transitionTo: search {"queryParams":{"q":"berlin","selected":null,"lat":"52.5200","lon":"13.4050"}}',
|
||||
'transitionTo: search {"queryParams":{"q":"berlin","selected":null,"category":null,"lat":"52.5200","lon":"13.4050"}}',
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -134,4 +134,96 @@ module('Integration | Component | search-box', function (hooks) {
|
||||
|
||||
assert.verifySteps(['search: cafe, 52.52, 13.405']);
|
||||
});
|
||||
|
||||
test('it allows typing even when controlled by parent with a query argument', async function (assert) {
|
||||
class MockPhotonService extends Service {
|
||||
async search() {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
this.owner.register('service:photon', MockPhotonService);
|
||||
|
||||
this.query = '';
|
||||
this.updateQuery = (val) => {
|
||||
this.set('query', val);
|
||||
};
|
||||
this.noop = () => {};
|
||||
|
||||
await render(
|
||||
<template>
|
||||
<SearchBox
|
||||
@query={{this.query}}
|
||||
@onQueryChange={{this.updateQuery}}
|
||||
@onToggleMenu={{this.noop}}
|
||||
/>
|
||||
</template>
|
||||
);
|
||||
|
||||
// Initial state
|
||||
assert.dom('.search-input').hasValue('');
|
||||
|
||||
// Simulate typing
|
||||
await fillIn('.search-input', 't');
|
||||
assert.dom('.search-input').hasValue('t', 'Input should show "t"');
|
||||
|
||||
await fillIn('.search-input', 'te');
|
||||
assert.dom('.search-input').hasValue('te', 'Input should show "te"');
|
||||
|
||||
// Simulate external update (e.g. chip click)
|
||||
this.set('query', 'restaurant');
|
||||
// wait for re-render
|
||||
await click('.search-input'); // just to trigger a change cycle or ensure stability
|
||||
assert
|
||||
.dom('.search-input')
|
||||
.hasValue('restaurant', 'Input should update from external change');
|
||||
});
|
||||
|
||||
test('it triggers category search with current location when clicking category result', async function (assert) {
|
||||
// Mock MapUi Service
|
||||
class MockMapUiService extends Service {
|
||||
currentCenter = { lat: 51.5074, lon: -0.1278 };
|
||||
setSearchBoxFocus() {}
|
||||
}
|
||||
this.owner.register('service:map-ui', MockMapUiService);
|
||||
|
||||
// Mock Photon Service
|
||||
class MockPhotonService extends Service {
|
||||
async search() {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
this.owner.register('service:photon', MockPhotonService);
|
||||
|
||||
// Mock Router Service
|
||||
class MockRouterService extends Service {
|
||||
transitionTo(routeName, options) {
|
||||
assert.step(`transitionTo: ${routeName} ${JSON.stringify(options)}`);
|
||||
}
|
||||
}
|
||||
this.owner.register('service:router', MockRouterService);
|
||||
|
||||
this.noop = () => {};
|
||||
await render(
|
||||
<template><SearchBox @onToggleMenu={{this.noop}} /></template>
|
||||
);
|
||||
|
||||
// Type "Resta" to trigger "Restaurants" category match
|
||||
await fillIn('.search-input', 'Resta');
|
||||
|
||||
// Wait for debounce (300ms) + execution
|
||||
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
await delay(400);
|
||||
|
||||
// The first result should be the category match
|
||||
assert.dom('.search-result-item').exists({ count: 1 });
|
||||
assert.dom('.result-title').hasText('Restaurants');
|
||||
|
||||
// Click the result
|
||||
await click('.search-result-item');
|
||||
|
||||
// Assert transition with lat/lon from map center
|
||||
assert.verifySteps([
|
||||
'transitionTo: search {"queryParams":{"q":"Restaurants","category":"restaurants","selected":null,"lat":"51.5074","lon":"-0.1278"}}',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -125,4 +125,60 @@ module('Unit | Route | place', function (hooks) {
|
||||
|
||||
assert.notOk(fetchCalled, 'fetchOsmObject should NOT be called for nodes');
|
||||
});
|
||||
|
||||
test('setupController triggers checkUpdates', async function (assert) {
|
||||
let route = this.owner.lookup('route:place');
|
||||
|
||||
// Stub Storage Service
|
||||
let refreshPlaceCalled = false;
|
||||
class StorageStub extends Service {
|
||||
async refreshPlace(place) {
|
||||
refreshPlaceCalled = true;
|
||||
assert.strictEqual(place.id, '123', 'Passed correct place to storage');
|
||||
return {
|
||||
...place,
|
||||
title: 'Updated Title',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Stub MapUi Service
|
||||
let selectPlaceCalled = false;
|
||||
class MapUiStub extends Service {
|
||||
selectPlace(place, options) {
|
||||
selectPlaceCalled = true;
|
||||
assert.strictEqual(
|
||||
place.title,
|
||||
'Updated Title',
|
||||
'Selected updated place'
|
||||
);
|
||||
assert.ok(options.preventZoom, 'Prevented zoom on update');
|
||||
}
|
||||
stopSearch() {}
|
||||
}
|
||||
|
||||
this.owner.register('service:storage', StorageStub);
|
||||
this.owner.register('service:map-ui', MapUiStub);
|
||||
|
||||
let model = {
|
||||
id: '123',
|
||||
osmId: '456',
|
||||
osmType: 'node',
|
||||
title: 'Original Title',
|
||||
};
|
||||
|
||||
let controller = {};
|
||||
|
||||
// Trigger setupController
|
||||
route.setupController(controller, model);
|
||||
|
||||
// checkUpdates is async and not awaited in setupController, so we need to wait a tick
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
|
||||
assert.ok(refreshPlaceCalled, 'refreshPlace should be called');
|
||||
assert.ok(
|
||||
selectPlaceCalled,
|
||||
'mapUi.selectPlace should be called with update'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -251,4 +251,45 @@ module('Unit | Service | osm', function (hooks) {
|
||||
[30, 30],
|
||||
]);
|
||||
});
|
||||
|
||||
test('getCategoryPois uses cache when lat/lon matches', async function (assert) {
|
||||
let service = this.owner.lookup('service:osm');
|
||||
|
||||
// Mock settings
|
||||
service.settings = { overpassApi: 'http://test-api' };
|
||||
|
||||
// Mock fetchWithRetry
|
||||
let fetchCount = 0;
|
||||
service.fetchWithRetry = async () => {
|
||||
fetchCount++;
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
elements: [{ id: 1, type: 'node', tags: { name: 'Test' } }],
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
const bounds = { minLat: 0, minLon: 0, maxLat: 1, maxLon: 1 };
|
||||
|
||||
// First call - should fetch
|
||||
await service.getCategoryPois(bounds, 'restaurants', 52.5, 13.4);
|
||||
assert.strictEqual(fetchCount, 1, 'First call should trigger fetch');
|
||||
|
||||
// Second call with same lat/lon - should cache
|
||||
await service.getCategoryPois(bounds, 'restaurants', 52.5, 13.4);
|
||||
assert.strictEqual(
|
||||
fetchCount,
|
||||
1,
|
||||
'Second call with same lat/lon should use cache'
|
||||
);
|
||||
|
||||
// Third call with diff lat/lon - should fetch
|
||||
await service.getCategoryPois(bounds, 'restaurants', 52.6, 13.5);
|
||||
assert.strictEqual(
|
||||
fetchCount,
|
||||
2,
|
||||
'Call with different lat/lon should trigger fetch'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
202
tests/unit/services/storage-test.js
Normal file
202
tests/unit/services/storage-test.js
Normal file
@@ -0,0 +1,202 @@
|
||||
import { module, test } from 'qunit';
|
||||
import { setupTest } from 'marco/tests/helpers';
|
||||
import Service from '@ember/service';
|
||||
|
||||
module('Unit | Service | storage', function (hooks) {
|
||||
setupTest(hooks);
|
||||
|
||||
test('refreshPlace skips invalid places', async function (assert) {
|
||||
let service = this.owner.lookup('service:storage');
|
||||
let result = await service.refreshPlace({});
|
||||
assert.strictEqual(result, null);
|
||||
});
|
||||
|
||||
test('refreshPlace detects coordinate drift', async function (assert) {
|
||||
let service = this.owner.lookup('service:storage');
|
||||
|
||||
// Stub OSM Service
|
||||
class OsmStub extends Service {
|
||||
async fetchOsmObject(id, type) {
|
||||
return {
|
||||
osmId: id,
|
||||
osmType: type,
|
||||
lat: 52.5201, // Changed significantly from 52.5200
|
||||
lon: 13.405,
|
||||
osmTags: { name: 'Foo' },
|
||||
};
|
||||
}
|
||||
}
|
||||
this.owner.register('service:osm', OsmStub);
|
||||
|
||||
// Mock storage update
|
||||
let updatePlaceCalled = false;
|
||||
service.updatePlace = async (place) => {
|
||||
updatePlaceCalled = true;
|
||||
return place;
|
||||
};
|
||||
|
||||
let place = {
|
||||
id: '123',
|
||||
osmId: '456',
|
||||
osmType: 'node',
|
||||
lat: 52.52,
|
||||
lon: 13.405,
|
||||
osmTags: { name: 'Foo' },
|
||||
title: 'Foo',
|
||||
};
|
||||
|
||||
let result = await service.refreshPlace(place);
|
||||
|
||||
assert.ok(updatePlaceCalled, 'updatePlace should be called');
|
||||
assert.strictEqual(result.lat, 52.5201, 'Latitude updated');
|
||||
});
|
||||
|
||||
test('refreshPlace ignores tiny coordinate drift', async function (assert) {
|
||||
let service = this.owner.lookup('service:storage');
|
||||
|
||||
class OsmStub extends Service {
|
||||
async fetchOsmObject(id, type) {
|
||||
return {
|
||||
osmId: id,
|
||||
osmType: type,
|
||||
lat: 52.5200005, // Tiny change (< 0.00001)
|
||||
lon: 13.405,
|
||||
osmTags: { name: 'Foo' },
|
||||
};
|
||||
}
|
||||
}
|
||||
this.owner.register('service:osm', OsmStub);
|
||||
|
||||
let updatePlaceCalled = false;
|
||||
service.updatePlace = async () => {
|
||||
updatePlaceCalled = true;
|
||||
};
|
||||
|
||||
let place = {
|
||||
id: '123',
|
||||
osmId: '456',
|
||||
osmType: 'node',
|
||||
lat: 52.52,
|
||||
lon: 13.405,
|
||||
osmTags: { name: 'Foo' },
|
||||
title: 'Foo',
|
||||
};
|
||||
|
||||
await service.refreshPlace(place);
|
||||
|
||||
assert.notOk(updatePlaceCalled, 'updatePlace should NOT be called');
|
||||
});
|
||||
|
||||
test('refreshPlace detects tag changes', async function (assert) {
|
||||
let service = this.owner.lookup('service:storage');
|
||||
|
||||
class OsmStub extends Service {
|
||||
async fetchOsmObject(id, type) {
|
||||
return {
|
||||
osmId: id,
|
||||
osmType: type,
|
||||
lat: 52.52,
|
||||
lon: 13.405,
|
||||
osmTags: { name: 'Bar' }, // Changed name
|
||||
};
|
||||
}
|
||||
}
|
||||
this.owner.register('service:osm', OsmStub);
|
||||
|
||||
let updatePlaceCalled = false;
|
||||
service.updatePlace = async (place) => {
|
||||
updatePlaceCalled = true;
|
||||
return place;
|
||||
};
|
||||
|
||||
let place = {
|
||||
id: '123',
|
||||
osmId: '456',
|
||||
osmType: 'node',
|
||||
lat: 52.52,
|
||||
lon: 13.405,
|
||||
osmTags: { name: 'Foo' },
|
||||
title: 'Foo',
|
||||
};
|
||||
|
||||
let result = await service.refreshPlace(place);
|
||||
|
||||
assert.ok(updatePlaceCalled, 'updatePlace should be called');
|
||||
assert.strictEqual(result.osmTags.name, 'Bar', 'Tags updated');
|
||||
});
|
||||
|
||||
test('refreshPlace updates title if it was default', async function (assert) {
|
||||
let service = this.owner.lookup('service:storage');
|
||||
|
||||
class OsmStub extends Service {
|
||||
async fetchOsmObject(id, type) {
|
||||
return {
|
||||
osmId: id,
|
||||
osmType: type,
|
||||
lat: 52.52,
|
||||
lon: 13.405,
|
||||
osmTags: { name: 'New Name' },
|
||||
};
|
||||
}
|
||||
}
|
||||
this.owner.register('service:osm', OsmStub);
|
||||
|
||||
service.updatePlace = async (place) => place;
|
||||
|
||||
let place = {
|
||||
id: '123',
|
||||
osmId: '456',
|
||||
osmType: 'node',
|
||||
lat: 52.52,
|
||||
lon: 13.405,
|
||||
osmTags: { name: 'Old Name' },
|
||||
title: 'Old Name', // Matches default
|
||||
};
|
||||
|
||||
let result = await service.refreshPlace(place);
|
||||
|
||||
assert.strictEqual(result.title, 'New Name', 'Title should update');
|
||||
});
|
||||
|
||||
test('refreshPlace preserves custom title', async function (assert) {
|
||||
let service = this.owner.lookup('service:storage');
|
||||
|
||||
class OsmStub extends Service {
|
||||
async fetchOsmObject(id, type) {
|
||||
return {
|
||||
osmId: id,
|
||||
osmType: type,
|
||||
lat: 52.52,
|
||||
lon: 13.405,
|
||||
osmTags: { name: 'New Name' },
|
||||
};
|
||||
}
|
||||
}
|
||||
this.owner.register('service:osm', OsmStub);
|
||||
|
||||
service.updatePlace = async (place) => place;
|
||||
|
||||
let place = {
|
||||
id: '123',
|
||||
osmId: '456',
|
||||
osmType: 'node',
|
||||
lat: 52.52,
|
||||
lon: 13.405,
|
||||
osmTags: { name: 'Old Name' },
|
||||
title: 'My Custom Place', // User renamed it
|
||||
};
|
||||
|
||||
let result = await service.refreshPlace(place);
|
||||
|
||||
assert.strictEqual(
|
||||
result.title,
|
||||
'My Custom Place',
|
||||
'Title should NOT update'
|
||||
);
|
||||
assert.strictEqual(
|
||||
result.osmTags.name,
|
||||
'New Name',
|
||||
'Tags should still update'
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user