Compare commits
50 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
2b219fe0cf
|
|||
|
9fd6c4d64d
|
|||
|
8e5b2c7439
|
|||
|
0f29430e1a
|
|||
|
0059d89cc3
|
|||
|
54e2766dc4
|
|||
|
5978f67d48
|
|||
|
d72e5f3de2
|
|||
|
582ab4f8b3
|
|||
|
0ac6db65cb
|
|||
|
86b20fd474
|
|||
|
8478e00253
|
|||
|
818ec35071
|
|||
|
46605dbd32
|
|||
|
bcc51efecc
|
|||
|
8bec4b978e
|
|||
|
cd9676047d
|
|||
|
a92b44ec13
|
|||
|
0c2d1f8419
|
|||
|
bb77ed8337
|
|||
|
438bf0c31c
|
|||
|
af57e7fe57
|
|||
|
9183e3c366
|
|||
|
7e98b6796c
|
|||
|
8e9beb16de
|
|||
|
b083c1d001
|
|||
|
4008a8c883
|
|||
|
eb7cff7ff5
|
|||
|
db6478e353
|
|||
|
b39d92b7c4
|
|||
|
aa99e5d766
|
|||
|
5fd4ebe184
|
|||
|
f2a2d910a0
|
|||
|
6b37508f66
|
|||
|
8106009677
|
|||
|
07489c43a4
|
|||
|
a4e375cb51
|
|||
|
b680769eac
|
|||
|
4a609c8388
|
|||
|
cfcaaea3ec
|
|||
|
2f440d4971
|
|||
|
1c6cbe6b0f
|
|||
|
bdd5db157c
|
|||
|
f7c40095d5
|
|||
|
579892067e
|
|||
|
48f87f98d6
|
|||
|
3cd1b32af9
|
|||
|
462404b53e
|
|||
|
e3147caa90
|
|||
|
7c11fefdb7
|
@@ -1,156 +1,74 @@
|
|||||||
# Project Status: Marco
|
# Project Status: Marco
|
||||||
|
|
||||||
**Last Updated:** Tue Feb 24 2026
|
**Last Updated:** Wed Mar 18 2026
|
||||||
|
|
||||||
## Project Context
|
## 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
|
## What We Have Done
|
||||||
|
|
||||||
### 1. Map Integration
|
### 1. Map Integration
|
||||||
|
|
||||||
- Set up OpenLayers in `app/components/map.gjs` (class-based component).
|
- **Vector Tiles:** Using **OpenFreeMap Liberty** style with a hybrid click handler (Visual Tiles + Overpass API fallback).
|
||||||
- Switched tiles to **OpenFreeMap Liberty** style (supports vector POIs).
|
- **Smart Interaction:**
|
||||||
- Implemented a hybrid click handler:
|
- **Hit Tolerance:** 10px buffer for easier mobile tapping.
|
||||||
- Detects clicks on visual vector tiles.
|
- **Auto-Pan:** Selected pins automatically center in the visible area (respecting bottom sheets/sidebars).
|
||||||
- Falls back to fetching authoritative data from an **Overpass API** service.
|
- **Smart Zoom:** `zoomToBbox` fits complex geometries (ways/relations) with dynamic padding, only zooming out to fit.
|
||||||
- **Logic Upgrade:** Map intelligently detects if _any_ sidebar/pane is open and handles outside clicks to close them instead of initiating new searches.
|
- **Visuals:** Custom "Red Pin" overlay with drop animation. Selected OSM ways/relations show distinct blue outlines.
|
||||||
- **Optimization:** Added **10px hit tolerance** for easier tapping on mobile devices.
|
- **Geolocation:** Robust "Locate Me" with dynamic zoom and accuracy visualization.
|
||||||
- **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.
|
|
||||||
|
|
||||||
### 2. RemoteStorage Module (`@remotestorage/module-places`)
|
### 2. RemoteStorage Module (`@remotestorage/module-places`)
|
||||||
|
|
||||||
- Created a custom TypeScript module in `vendor/remotestorage-module-places/`.
|
- **Custom Module:** Handles `place` objects with Geohash-based partitioning (`<2-char>/<2-char>/<id>`).
|
||||||
- **Schema:** `place` object containing `id` (ULID), `title`, `lat`, `lon`, `geohash`, `osmId`, `url`, etc.
|
- **Optimization:** Supports efficient spatial querying via prefix loading.
|
||||||
- **Storage Path:** Nested `<2-char>/<2-char>/<id>` (based on geohash) for scalability.
|
- **Lists Support:** Manages collection-based organization (e.g., "To Visit", "Favorites").
|
||||||
- **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.
|
|
||||||
|
|
||||||
### 3. App Infrastructure & Build
|
### 3. App Infrastructure
|
||||||
|
|
||||||
- **Services:**
|
- **Services:**
|
||||||
- `storage.js`: Initializes RemoteStorage, claims access, enables caching, and sets up the widget. Consumes the new `getPlaces` API.
|
- `storage.js`: Manages RemoteStorage, caching, and the new **Lists** feature (`to-go`, `to-do`).
|
||||||
- **Optimization:** Implemented **Debounced Reload** (200ms) for bookmark updates to handle rapid change events efficiently.
|
- `osm.js`: Fetches/caches POIs from Overpass API (configurable endpoints).
|
||||||
- **Optimization:** Correctly handles deletion/updates by clearing stale data for reloaded geohash sectors.
|
- `settings.js`: Persists user preferences (e.g., API provider).
|
||||||
- `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`.
|
|
||||||
- **UI Components:**
|
- **UI Components:**
|
||||||
- `places-sidebar.gjs`: Displays a list of nearby POIs.
|
- **Responsive Layout:** Sidebar transforms into a Bottom Sheet on mobile.
|
||||||
- **Layout:** Responsive design that transforms into a **Bottom Sheet** (50% height) on mobile screens (`<=768px`) with rounded corners and upward shadow.
|
- **Place Details:** Rich info (Address, Socials, Opening Hours) with distinct "Actions" and "Meta" sections.
|
||||||
- `place-details.gjs`: Dedicated component for displaying rich place information.
|
- **App Menu:** Comprehensive settings and about section, implemented as a secondary sidebar.
|
||||||
- **Features:** Icons (via `feather-icons`), Address, Phone, Website, Opening Hours, Cuisine, Wikipedia.
|
- **CI/CD:** Gitea Actions for automated testing and release drafting.
|
||||||
- **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.
|
|
||||||
|
|
||||||
### 4. Routing & Architecture (Refactored)
|
### 4. Routing & Architecture
|
||||||
|
|
||||||
- **URL-Driven Architecture:** Moved from service-based state to proper route-based state management.
|
- **URL-Driven:** `/search` (list) and `/place/:id` (details) routes.
|
||||||
- `/search?lat=...&lon=...&q=...`: Displays search results list.
|
- **Smart Navigation:**
|
||||||
- `/place/:place_id`: Displays details for a specific place (OSM POI or Bookmark).
|
- Direct hits redirect to details.
|
||||||
- **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.
|
- Search results automatically resolve to existing **Bookmarks**.
|
||||||
- **Back Button Support:** Browser history works correctly. Navigating "Back" from a place returns to the cached search results instantly without network requests.
|
- "Back" navigation returns to cached search results instantly.
|
||||||
- **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.
|
|
||||||
|
|
||||||
### 5. Creation & Editing Workflow
|
### 5. Features
|
||||||
|
|
||||||
- **Create Place:**
|
- **Search:** Typo-tolerant **Photon API** integration with location bias and debounce.
|
||||||
- Implemented `/place/new` route for creating new private places.
|
- **Creation & Editing:**
|
||||||
- **UX:** Map displays a central crosshair for precise location selection.
|
- "Crosshair" mode for precise location picking.
|
||||||
- **Mobile Optimization:**
|
- Edit Title/Description for saved places.
|
||||||
- Disabled map inertia (`kinetic: false`) to ensure the map stops exactly where the finger releases.
|
- **Lists:** Users can add places to default lists ("To Go", "To Do") directly from the details view.
|
||||||
- `PlaceEditForm` conditionally disables autofocus on mobile screens (`<= 768px`) to prevent the onscreen keyboard from obscuring the map view immediately.
|
- **Socials:** Place details now include Email, Facebook, and Instagram links.
|
||||||
- Responsive crosshair sizing (48px desktop / 24px mobile).
|
- **Data Sync:** Auto-refreshes OSM data (coords/tags) for saved places on view, preserving custom titles.
|
||||||
- **Persistence:** Form data (Title, Description) and Map coordinates are securely saved to RemoteStorage via `storage.storePlace`.
|
|
||||||
|
|
||||||
### 6. Search Functionality
|
|
||||||
|
|
||||||
- **Provider:** Integrated **Photon API** (by Komoot) via `app/services/photon.js` for high-quality, typo-tolerant OpenStreetMap search.
|
|
||||||
- **UI:** `SearchBoxComponent` implements a responsive search bar with instant autocomplete.
|
|
||||||
- **Debounced Input:** 300ms delay to prevent excessive API calls.
|
|
||||||
- **Location Bias:** Automatically biases search results towards the current map center to show relevant local places first.
|
|
||||||
- **Direct Navigation:** Selecting a result with a valid OSM ID navigates directly to the specific place details (`/place/osm:type:id`).
|
|
||||||
- **Resilience:** Implemented retry logic (exponential backoff/fixed delay) for network errors and rate limits (429).
|
|
||||||
- **Data Normalization:** Search results are normalized to match the internal POI schema, ensuring consistent rendering across Search and Map views.
|
|
||||||
|
|
||||||
## Current State
|
## Current State
|
||||||
|
|
||||||
- **Repo:** The app runs via `pnpm start`.
|
- **Repo:** Runs via `pnpm start`.
|
||||||
- **Workflow:**
|
- **Workflow:**
|
||||||
1. User pans map -> `moveend` triggers `storage.loadPlacesInBounds`.
|
1. **Explore:** Pan/Zoom loads bookmarks from RemoteStorage.
|
||||||
2. User clicks map -> Route transition to `/search` -> "Pulse" animation -> hybrid hit detection (Visual Tile vs Overpass).
|
2. **Search:** Query via Photon -> List or Direct Result.
|
||||||
3. **Navigation:**
|
3. **View:** Details pane (Sidebar/Bottom Sheet) shows rich info + social links.
|
||||||
- If direct match: Redirect to `/place/:id`.
|
4. **Action:**
|
||||||
- If multiple results: Show `/search` list view.
|
- **Save:** Persist to RemoteStorage.
|
||||||
4. Sidebar displays details via `<PlaceDetails>` component (Bottom sheet on mobile).
|
- **Organize:** Add to "To Go" / "To Do" lists.
|
||||||
5. **Creation:** User clicks "Create Place" -> Enters creation mode (crosshair) -> Positions map -> Enters details -> Save.
|
- **Edit:** Custom Title/Description.
|
||||||
6. **Persistence:** RemoteStorage change event -> Debounced reload updates the map reactive-ly.
|
5. **Sync:** Background check updates OSM data if changed.
|
||||||
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
|
## Next Steps
|
||||||
|
|
||||||
- `app/services/osm.js`
|
1. **Testing:** Add automated tests for the new Lists logic and Geohash coverage.
|
||||||
- `app/components/map.gjs`
|
2. **Performance:** Monitor with large datasets.
|
||||||
- `app/routes/place.js`
|
3. **Refinement:** Polish list UI and interactions.
|
||||||
- `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`.
|
|
||||||
|
|||||||
@@ -6,10 +6,17 @@ import { on } from '@ember/modifier';
|
|||||||
import Icon from '#components/icon';
|
import Icon from '#components/icon';
|
||||||
import UserMenu from '#components/user-menu';
|
import UserMenu from '#components/user-menu';
|
||||||
import SearchBox from '#components/search-box';
|
import SearchBox from '#components/search-box';
|
||||||
|
import CategoryChips from '#components/category-chips';
|
||||||
|
|
||||||
export default class AppHeaderComponent extends Component {
|
export default class AppHeaderComponent extends Component {
|
||||||
@service storage;
|
@service storage;
|
||||||
|
@service settings;
|
||||||
@tracked isUserMenuOpen = false;
|
@tracked isUserMenuOpen = false;
|
||||||
|
@tracked searchQuery = '';
|
||||||
|
|
||||||
|
get hasQuery() {
|
||||||
|
return !!this.searchQuery;
|
||||||
|
}
|
||||||
|
|
||||||
@action
|
@action
|
||||||
toggleUserMenu() {
|
toggleUserMenu() {
|
||||||
@@ -21,12 +28,34 @@ export default class AppHeaderComponent extends Component {
|
|||||||
this.isUserMenuOpen = false;
|
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>
|
<template>
|
||||||
<header class="app-header">
|
<header class="app-header">
|
||||||
<div class="header-left">
|
<div class="header-left">
|
||||||
<SearchBox @onToggleMenu={{@onToggleMenu}} />
|
<SearchBox
|
||||||
|
@query={{this.searchQuery}}
|
||||||
|
@onToggleMenu={{@onToggleMenu}}
|
||||||
|
@onQueryChange={{this.handleQueryChange}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{{#if this.settings.showQuickSearchButtons}}
|
||||||
|
<div class="header-center {{if this.hasQuery 'searching'}}">
|
||||||
|
<CategoryChips @onSelect={{this.handleChipSelect}} />
|
||||||
|
</div>
|
||||||
|
{{/if}}
|
||||||
|
|
||||||
<div class="header-right">
|
<div class="header-right">
|
||||||
<div class="user-menu-container">
|
<div class="user-menu-container">
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -111,6 +111,22 @@ import Icon from '#components/icon';
|
|||||||
</a>
|
</a>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</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>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ export default class AppMenu extends Component {
|
|||||||
}
|
}
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="sidebar settings-pane">
|
<div class="sidebar app-menu-pane">
|
||||||
{{#if (eq this.currentView "menu")}}
|
{{#if (eq this.currentView "menu")}}
|
||||||
<AppMenuHome @onNavigate={{this.setView}} @onClose={{@onClose}} />
|
<AppMenuHome @onNavigate={{this.setView}} @onClose={{@onClose}} />
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,11 @@ export default class AppMenuSettings extends Component {
|
|||||||
this.settings.updateMapKinetic(event.target.value === 'true');
|
this.settings.updateMapKinetic(event.target.value === 'true');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
toggleQuickSearchButtons(event) {
|
||||||
|
this.settings.updateShowQuickSearchButtons(event.target.value === 'true');
|
||||||
|
}
|
||||||
|
|
||||||
@action
|
@action
|
||||||
updatePhotonApi(event) {
|
updatePhotonApi(event) {
|
||||||
this.settings.updatePhotonApi(event.target.value);
|
this.settings.updatePhotonApi(event.target.value);
|
||||||
@@ -36,6 +41,30 @@ export default class AppMenuSettings extends Component {
|
|||||||
|
|
||||||
<div class="sidebar-content">
|
<div class="sidebar-content">
|
||||||
<section class="settings-section">
|
<section class="settings-section">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="show-quick-search">Quick search buttons visible</label>
|
||||||
|
<select
|
||||||
|
id="show-quick-search"
|
||||||
|
class="form-control"
|
||||||
|
{{on "change" this.toggleQuickSearchButtons}}
|
||||||
|
>
|
||||||
|
<option
|
||||||
|
value="true"
|
||||||
|
selected={{if this.settings.showQuickSearchButtons "selected"}}
|
||||||
|
>
|
||||||
|
Yes
|
||||||
|
</option>
|
||||||
|
<option
|
||||||
|
value="false"
|
||||||
|
selected={{unless
|
||||||
|
this.settings.showQuickSearchButtons
|
||||||
|
"selected"
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
No
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="map-kinetic">Map Inertia (Kinetic Panning)</label>
|
<label for="map-kinetic">Map Inertia (Kinetic Panning)</label>
|
||||||
<select
|
<select
|
||||||
|
|||||||
57
app/components/category-chips.gjs
Normal file
57
app/components/category-chips.gjs
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import Component from '@glimmer/component';
|
||||||
|
import { service } from '@ember/service';
|
||||||
|
import { action } from '@ember/object';
|
||||||
|
import { on } from '@ember/modifier';
|
||||||
|
import { fn } from '@ember/helper';
|
||||||
|
import Icon from '#components/icon';
|
||||||
|
import { POI_CATEGORIES } from '../utils/poi-categories';
|
||||||
|
import { eq, and } from 'ember-truth-helpers';
|
||||||
|
|
||||||
|
export default class CategoryChipsComponent extends Component {
|
||||||
|
@service router;
|
||||||
|
@service mapUi;
|
||||||
|
|
||||||
|
get categories() {
|
||||||
|
return POI_CATEGORIES;
|
||||||
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
searchCategory(category) {
|
||||||
|
// If passed an onSelect action, call it (e.g. to clear search box)
|
||||||
|
if (this.args.onSelect) {
|
||||||
|
this.args.onSelect(category);
|
||||||
|
}
|
||||||
|
|
||||||
|
let queryParams = { category: category.id, q: null };
|
||||||
|
|
||||||
|
if (this.mapUi.currentCenter) {
|
||||||
|
const { lat, lon } = this.mapUi.currentCenter;
|
||||||
|
queryParams.lat = parseFloat(lat).toFixed(4);
|
||||||
|
queryParams.lon = parseFloat(lon).toFixed(4);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.router.transitionTo('search', { queryParams });
|
||||||
|
}
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="category-chips-scroll">
|
||||||
|
<div class="category-chips-container">
|
||||||
|
{{#each this.categories as |category|}}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="category-chip"
|
||||||
|
{{on "click" (fn this.searchCategory category)}}
|
||||||
|
aria-label={{category.label}}
|
||||||
|
disabled={{and
|
||||||
|
(eq this.mapUi.loadingState.type "category")
|
||||||
|
(eq this.mapUi.loadingState.value category.id)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Icon @name={{category.icon}} @size={{16}} />
|
||||||
|
<span>{{category.label}}</span>
|
||||||
|
</button>
|
||||||
|
{{/each}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import Component from '@glimmer/component';
|
import Component from '@glimmer/component';
|
||||||
import { htmlSafe } from '@ember/template';
|
import { htmlSafe } from '@ember/template';
|
||||||
import { getIcon } from '../utils/icons';
|
import { getIcon, isIconFilled } from '../utils/icons';
|
||||||
|
|
||||||
export default class IconComponent extends Component {
|
export default class IconComponent extends Component {
|
||||||
get svg() {
|
get svg() {
|
||||||
@@ -25,10 +25,14 @@ export default class IconComponent extends Component {
|
|||||||
return this.args.title || '';
|
return this.args.title || '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get isFilled() {
|
||||||
|
return this.args.filled || isIconFilled(this.args.name);
|
||||||
|
}
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
{{#if this.svg}}
|
{{#if this.svg}}
|
||||||
<span
|
<span
|
||||||
class="icon {{if @filled 'icon-filled'}}"
|
class="icon {{if this.isFilled 'icon-filled'}}"
|
||||||
style={{this.style}}
|
style={{this.style}}
|
||||||
title={{this.title}}
|
title={{this.title}}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import Component from '@glimmer/component';
|
|||||||
import { service } from '@ember/service';
|
import { service } from '@ember/service';
|
||||||
import { modifier } from 'ember-modifier';
|
import { modifier } from 'ember-modifier';
|
||||||
import 'ol/ol.css';
|
import 'ol/ol.css';
|
||||||
import Map from 'ol/Map.js';
|
import OlMap from 'ol/Map.js';
|
||||||
import { defaults as defaultControls, Control } from 'ol/control.js';
|
import { defaults as defaultControls, Control } from 'ol/control.js';
|
||||||
import { defaults as defaultInteractions, DragPan } from 'ol/interaction.js';
|
import { defaults as defaultInteractions, DragPan } from 'ol/interaction.js';
|
||||||
import Kinetic from 'ol/Kinetic.js';
|
import Kinetic from 'ol/Kinetic.js';
|
||||||
@@ -16,8 +16,10 @@ import Feature from 'ol/Feature.js';
|
|||||||
import GeoJSON from 'ol/format/GeoJSON.js';
|
import GeoJSON from 'ol/format/GeoJSON.js';
|
||||||
import Point from 'ol/geom/Point.js';
|
import Point from 'ol/geom/Point.js';
|
||||||
import Geolocation from 'ol/Geolocation.js';
|
import Geolocation from 'ol/Geolocation.js';
|
||||||
import { Style, Circle, Fill, Stroke } from 'ol/style.js';
|
import { Style, Circle, Fill, Stroke, Icon } from 'ol/style.js';
|
||||||
import { apply } from 'ol-mapbox-style';
|
import { apply } from 'ol-mapbox-style';
|
||||||
|
import { getIcon } from '../utils/icons';
|
||||||
|
import { getIconNameForTags } from '../utils/osm-icons';
|
||||||
|
|
||||||
export default class MapComponent extends Component {
|
export default class MapComponent extends Component {
|
||||||
@service osm;
|
@service osm;
|
||||||
@@ -28,6 +30,7 @@ export default class MapComponent extends Component {
|
|||||||
|
|
||||||
mapInstance;
|
mapInstance;
|
||||||
bookmarkSource;
|
bookmarkSource;
|
||||||
|
searchResultsSource;
|
||||||
selectedShapeSource;
|
selectedShapeSource;
|
||||||
searchOverlay;
|
searchOverlay;
|
||||||
searchOverlayElement;
|
searchOverlayElement;
|
||||||
@@ -110,6 +113,101 @@ export default class MapComponent extends Component {
|
|||||||
zIndex: 10, // Ensure it sits above the map tiles
|
zIndex: 10, // Ensure it sits above the map tiles
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Create a vector source and layer for search results
|
||||||
|
this.searchResultsSource = new VectorSource();
|
||||||
|
const cachedIconUrls = new Map();
|
||||||
|
|
||||||
|
const searchResultStyle = (feature) => {
|
||||||
|
const originalPlace = feature.get('originalPlace');
|
||||||
|
// Some search results might be just the place object without separate tags
|
||||||
|
// If it's a raw place object, it might have osmTags property.
|
||||||
|
// Or it might be the tags object itself.
|
||||||
|
const tags = originalPlace.osmTags || originalPlace;
|
||||||
|
const iconName = getIconNameForTags(tags);
|
||||||
|
|
||||||
|
// Use 'default' key for the standard red dot marker. Use iconName as key if present.
|
||||||
|
const cacheKey = iconName || 'default';
|
||||||
|
|
||||||
|
if (!cachedIconUrls.has(cacheKey)) {
|
||||||
|
const markerColor =
|
||||||
|
getComputedStyle(document.documentElement)
|
||||||
|
.getPropertyValue('--marker-color-primary')
|
||||||
|
.trim() || '#ea4335';
|
||||||
|
|
||||||
|
// Default content: Red circle
|
||||||
|
let innerContent = `<circle cx="12" cy="12" r="8" fill="${markerColor}"/>`;
|
||||||
|
|
||||||
|
if (iconName) {
|
||||||
|
const rawSvg = getIcon(iconName);
|
||||||
|
if (rawSvg) {
|
||||||
|
// Pinhead icons are usually 15x15 viewBox="0 0 15 15".
|
||||||
|
// We want to center it on 12,12.
|
||||||
|
// A 12x12 icon centered at 12,12 means top-left at 6,6.
|
||||||
|
// However, since we are embedding a new SVG, we can just use x/y/width/height.
|
||||||
|
// But we need to strip the outer <svg> tag to embed the paths cleanly if we want full control,
|
||||||
|
// or we can nest the SVG. Nesting is safer.
|
||||||
|
|
||||||
|
// The rawSvg string contains <svg ...>...</svg>.
|
||||||
|
// We want to make it white. We can add a group with fill="white".
|
||||||
|
// But if the SVG has fill attributes, they override. Pinhead icons usually don't have fills.
|
||||||
|
|
||||||
|
// Let's strip the outer SVG tag to get the path content.
|
||||||
|
let content = rawSvg.trim();
|
||||||
|
const svgStart = content.indexOf('<svg');
|
||||||
|
const svgEnd = content.indexOf('>', svgStart);
|
||||||
|
const contentStart = svgEnd + 1;
|
||||||
|
const contentEnd = content.lastIndexOf('</svg>');
|
||||||
|
|
||||||
|
if (svgStart !== -1 && contentEnd !== -1) {
|
||||||
|
content = content.substring(contentStart, contentEnd);
|
||||||
|
}
|
||||||
|
|
||||||
|
// We render the red circle background, then the icon on top.
|
||||||
|
// Icon is scaled down slightly to fit nicely inside the circle.
|
||||||
|
// 15x15 scaled by 0.8 is 12x12.
|
||||||
|
// Translate to 6,6 to center.
|
||||||
|
innerContent = `
|
||||||
|
<circle cx="12" cy="12" r="8" fill="${markerColor}"/>
|
||||||
|
<g transform="translate(6, 6) scale(0.8)" fill="white">
|
||||||
|
${content}
|
||||||
|
</g>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const svg = `
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-4 -4 32 40" width="40" height="50">
|
||||||
|
<defs>
|
||||||
|
<filter id="shadow" x="-50%" y="-50%" width="200%" height="200%">
|
||||||
|
<feDropShadow dx="0" dy="2" stdDeviation="1.5" flood-color="black" flood-opacity="0.3"/>
|
||||||
|
</filter>
|
||||||
|
</defs>
|
||||||
|
<path d="M12 2C6.5 2 2 6.5 2 12C2 17.5 12 24 12 24C12 24 22 17.5 22 12C22 6.5 17.5 2 12 2Z" fill="white" filter="url(#shadow)"/>
|
||||||
|
${innerContent}
|
||||||
|
</svg>
|
||||||
|
`;
|
||||||
|
|
||||||
|
cachedIconUrls.set(
|
||||||
|
cacheKey,
|
||||||
|
'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(svg.trim())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Style({
|
||||||
|
image: new Icon({
|
||||||
|
src: cachedIconUrls.get(cacheKey),
|
||||||
|
anchor: [0.5, 0.65],
|
||||||
|
scale: 1,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const searchResultLayer = new VectorLayer({
|
||||||
|
source: this.searchResultsSource,
|
||||||
|
style: searchResultStyle,
|
||||||
|
zIndex: 11, // Above bookmarks (10)
|
||||||
|
});
|
||||||
|
|
||||||
// Default view settings
|
// Default view settings
|
||||||
let center = [14.21683569, 27.060114248];
|
let center = [14.21683569, 27.060114248];
|
||||||
let zoom = 2.661;
|
let zoom = 2.661;
|
||||||
@@ -141,9 +239,14 @@ export default class MapComponent extends Component {
|
|||||||
projection: 'EPSG:3857',
|
projection: 'EPSG:3857',
|
||||||
});
|
});
|
||||||
|
|
||||||
this.mapInstance = new Map({
|
this.mapInstance = new OlMap({
|
||||||
target: element,
|
target: element,
|
||||||
layers: [openfreemap, selectedShapeLayer, bookmarkLayer],
|
layers: [
|
||||||
|
openfreemap,
|
||||||
|
selectedShapeLayer,
|
||||||
|
searchResultLayer,
|
||||||
|
bookmarkLayer,
|
||||||
|
],
|
||||||
view: view,
|
view: view,
|
||||||
controls: defaultControls({
|
controls: defaultControls({
|
||||||
zoom: true,
|
zoom: true,
|
||||||
@@ -178,7 +281,7 @@ export default class MapComponent extends Component {
|
|||||||
const pinIcon = document.createElement('div');
|
const pinIcon = document.createElement('div');
|
||||||
pinIcon.className = 'selected-pin';
|
pinIcon.className = 'selected-pin';
|
||||||
// Simple SVG for Map Pin
|
// Simple SVG for Map Pin
|
||||||
pinIcon.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"></path><circle cx="12" cy="10" r="3" style="fill: #b31412; stroke: none;"></circle></svg>`;
|
pinIcon.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"></path><circle cx="12" cy="10" r="3" style="fill: var(--marker-color-dark); stroke: none;"></circle></svg>`;
|
||||||
|
|
||||||
const pinShadow = document.createElement('div');
|
const pinShadow = document.createElement('div');
|
||||||
pinShadow.className = 'selected-pin-shadow';
|
pinShadow.className = 'selected-pin-shadow';
|
||||||
@@ -464,6 +567,33 @@ export default class MapComponent extends Component {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
updateSearchResults = modifier(() => {
|
||||||
|
if (!this.searchResultsSource) return;
|
||||||
|
|
||||||
|
this.searchResultsSource.clear();
|
||||||
|
const results = this.mapUi.searchResults;
|
||||||
|
|
||||||
|
if (!results || results.length === 0) return;
|
||||||
|
|
||||||
|
const features = [];
|
||||||
|
results.forEach((place) => {
|
||||||
|
if (place.lat && place.lon) {
|
||||||
|
const feature = new Feature({
|
||||||
|
geometry: new Point(fromLonLat([place.lon, place.lat])),
|
||||||
|
name: place.title,
|
||||||
|
id: place.id,
|
||||||
|
isSearchResult: true,
|
||||||
|
originalPlace: place,
|
||||||
|
});
|
||||||
|
features.push(feature);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (features.length > 0) {
|
||||||
|
this.searchResultsSource.addFeatures(features);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Track the selected place from the UI Service (Router -> Map)
|
// Track the selected place from the UI Service (Router -> Map)
|
||||||
updateSelectedPin = modifier(() => {
|
updateSelectedPin = modifier(() => {
|
||||||
const selected = this.mapUi.selectedPlace;
|
const selected = this.mapUi.selectedPlace;
|
||||||
@@ -914,6 +1044,7 @@ export default class MapComponent extends Component {
|
|||||||
const [maxLon, maxLat] = toLonLat([extent[2], extent[3]]);
|
const [maxLon, maxLat] = toLonLat([extent[2], extent[3]]);
|
||||||
|
|
||||||
const bbox = { minLat, minLon, maxLat, maxLon };
|
const bbox = { minLat, minLon, maxLat, maxLon };
|
||||||
|
this.mapUi.updateBounds(bbox);
|
||||||
await this.storage.loadPlacesInBounds(bbox);
|
await this.storage.loadPlacesInBounds(bbox);
|
||||||
this.loadBookmarks(this.storage.placesInView);
|
this.loadBookmarks(this.storage.placesInView);
|
||||||
|
|
||||||
@@ -945,6 +1076,7 @@ export default class MapComponent extends Component {
|
|||||||
hitTolerance: 10,
|
hitTolerance: 10,
|
||||||
});
|
});
|
||||||
let clickedBookmark = null;
|
let clickedBookmark = null;
|
||||||
|
let clickedSearchResult = null;
|
||||||
let selectedFeatureName = null;
|
let selectedFeatureName = null;
|
||||||
|
|
||||||
if (features && features.length > 0) {
|
if (features && features.length > 0) {
|
||||||
@@ -953,8 +1085,12 @@ export default class MapComponent extends Component {
|
|||||||
console.debug(f);
|
console.debug(f);
|
||||||
}
|
}
|
||||||
const bookmarkFeature = features.find((f) => f.get('isBookmark'));
|
const bookmarkFeature = features.find((f) => f.get('isBookmark'));
|
||||||
|
const searchResultFeature = features.find((f) => f.get('isSearchResult'));
|
||||||
|
|
||||||
if (bookmarkFeature) {
|
if (bookmarkFeature) {
|
||||||
clickedBookmark = bookmarkFeature.get('originalPlace');
|
clickedBookmark = bookmarkFeature.get('originalPlace');
|
||||||
|
} else if (searchResultFeature) {
|
||||||
|
clickedSearchResult = searchResultFeature.get('originalPlace');
|
||||||
}
|
}
|
||||||
// Also get visual props for standard map click logic later
|
// Also get visual props for standard map click logic later
|
||||||
const props = features[0].getProperties();
|
const props = features[0].getProperties();
|
||||||
@@ -963,16 +1099,30 @@ export default class MapComponent extends Component {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Helper to transition with proper state
|
||||||
|
const transitionToPlace = (place) => {
|
||||||
|
// If we are currently in search mode OR have active search results,
|
||||||
|
// we want the "Back" button on the place details to return to the search results.
|
||||||
|
if (
|
||||||
|
this.router.currentRouteName === 'search' ||
|
||||||
|
(this.mapUi.currentSearch && this.mapUi.searchResults.length > 0)
|
||||||
|
) {
|
||||||
|
this.mapUi.returnToSearch = true;
|
||||||
|
}
|
||||||
|
this.mapUi.preventNextZoom = true;
|
||||||
|
this.router.transitionTo('place', place);
|
||||||
|
};
|
||||||
|
|
||||||
// Special handling when sidebar is OPEN
|
// Special handling when sidebar is OPEN
|
||||||
if (this.args.isSidebarOpen) {
|
if (this.args.isSidebarOpen) {
|
||||||
// If it's a bookmark, we allow "switching" to it even if sidebar is open
|
// If it's a bookmark or search result, we allow "switching" to it even if sidebar is open
|
||||||
if (clickedBookmark) {
|
const targetPlace = clickedBookmark || clickedSearchResult;
|
||||||
|
if (targetPlace) {
|
||||||
console.debug(
|
console.debug(
|
||||||
'Clicked bookmark while sidebar open (switching):',
|
'Clicked feature while sidebar open (switching):',
|
||||||
clickedBookmark
|
targetPlace
|
||||||
);
|
);
|
||||||
this.mapUi.preventNextZoom = true;
|
transitionToPlace(targetPlace);
|
||||||
this.router.transitionTo('place', clickedBookmark);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -986,8 +1136,13 @@ export default class MapComponent extends Component {
|
|||||||
// Normal behavior (sidebar is closed)
|
// Normal behavior (sidebar is closed)
|
||||||
if (clickedBookmark) {
|
if (clickedBookmark) {
|
||||||
console.debug('Clicked bookmark:', clickedBookmark);
|
console.debug('Clicked bookmark:', clickedBookmark);
|
||||||
this.mapUi.preventNextZoom = true;
|
transitionToPlace(clickedBookmark);
|
||||||
this.router.transitionTo('place', clickedBookmark);
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (clickedSearchResult) {
|
||||||
|
console.debug('Clicked search result:', clickedSearchResult);
|
||||||
|
transitionToPlace(clickedSearchResult);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1028,6 +1183,7 @@ export default class MapComponent extends Component {
|
|||||||
lat: lat.toFixed(6),
|
lat: lat.toFixed(6),
|
||||||
lon: lon.toFixed(6),
|
lon: lon.toFixed(6),
|
||||||
q: null, // Clear q to force spatial search
|
q: null, // Clear q to force spatial search
|
||||||
|
category: null, // Clear category to force spatial search
|
||||||
selected: selectedFeatureName || null,
|
selected: selectedFeatureName || null,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1040,6 +1196,7 @@ export default class MapComponent extends Component {
|
|||||||
{{this.setupMap}}
|
{{this.setupMap}}
|
||||||
{{this.updateInteractions}}
|
{{this.updateInteractions}}
|
||||||
{{this.updateBookmarks}}
|
{{this.updateBookmarks}}
|
||||||
|
{{this.updateSearchResults}}
|
||||||
{{this.updateSelectedPin}}
|
{{this.updateSelectedPin}}
|
||||||
{{this.syncPulse}}
|
{{this.syncPulse}}
|
||||||
{{this.syncCreationMode}}
|
{{this.syncCreationMode}}
|
||||||
|
|||||||
@@ -305,6 +305,7 @@ export default class PlaceDetails extends Component {
|
|||||||
type="button"
|
type="button"
|
||||||
class="btn btn-outline"
|
class="btn btn-outline"
|
||||||
title="Edit"
|
title="Edit"
|
||||||
|
disabled={{this.isEditing}}
|
||||||
{{on "click" this.startEditing}}
|
{{on "click" this.startEditing}}
|
||||||
>
|
>
|
||||||
<Icon @name="edit" @color="var(--link-color)" />
|
<Icon @name="edit" @color="var(--link-color)" />
|
||||||
@@ -316,9 +317,11 @@ export default class PlaceDetails extends Component {
|
|||||||
<div class="meta-info">
|
<div class="meta-info">
|
||||||
|
|
||||||
{{#if this.cuisine}}
|
{{#if this.cuisine}}
|
||||||
<p class="cuisine-info">
|
<p class="content-with-icon">
|
||||||
<strong>Cuisine:</strong>
|
<Icon @name="fork-and-knife" @title="Cuisine" />
|
||||||
|
<span>
|
||||||
{{this.cuisine}}
|
{{this.cuisine}}
|
||||||
|
</span>
|
||||||
</p>
|
</p>
|
||||||
{{/if}}
|
{{/if}}
|
||||||
|
|
||||||
@@ -390,7 +393,7 @@ export default class PlaceDetails extends Component {
|
|||||||
|
|
||||||
{{#if this.wikipedia}}
|
{{#if this.wikipedia}}
|
||||||
<p class="content-with-icon">
|
<p class="content-with-icon">
|
||||||
<Icon @name="wikipedia" @title="Wikipedia" @filled={{true}} />
|
<Icon @name="wikipedia" @title="Wikipedia" />
|
||||||
<span>
|
<span>
|
||||||
<a
|
<a
|
||||||
href="https://wikipedia.org/wiki/{{this.wikipedia}}"
|
href="https://wikipedia.org/wiki/{{this.wikipedia}}"
|
||||||
|
|||||||
@@ -146,7 +146,7 @@ export default class PlacesSidebar extends Component {
|
|||||||
|
|
||||||
get isNearbySearch() {
|
get isNearbySearch() {
|
||||||
const qp = this.router.currentRoute.queryParams;
|
const qp = this.router.currentRoute.queryParams;
|
||||||
return !qp.q && qp.lat && qp.lon;
|
return !qp.q && !qp.category && qp.lat && qp.lon;
|
||||||
}
|
}
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|||||||
@@ -7,38 +7,55 @@ import { fn } from '@ember/helper';
|
|||||||
import { task, timeout } from 'ember-concurrency';
|
import { task, timeout } from 'ember-concurrency';
|
||||||
import Icon from '#components/icon';
|
import Icon from '#components/icon';
|
||||||
import humanizeOsmTag from '../helpers/humanize-osm-tag';
|
import humanizeOsmTag from '../helpers/humanize-osm-tag';
|
||||||
import eq from 'ember-truth-helpers/helpers/eq';
|
import { POI_CATEGORIES } from '../utils/poi-categories';
|
||||||
|
import { eq, or } from 'ember-truth-helpers';
|
||||||
|
|
||||||
export default class SearchBoxComponent extends Component {
|
export default class SearchBoxComponent extends Component {
|
||||||
@service photon;
|
@service photon;
|
||||||
|
@service osm;
|
||||||
@service router;
|
@service router;
|
||||||
@service mapUi;
|
@service mapUi;
|
||||||
@service map; // Assuming we might need map context, but mostly we use router
|
@service map; // Assuming we might need map context, but mostly we use router
|
||||||
|
|
||||||
@tracked query = '';
|
@tracked _internalQuery = '';
|
||||||
@tracked results = [];
|
@tracked results = [];
|
||||||
@tracked isFocused = false;
|
@tracked isFocused = false;
|
||||||
@tracked isLoading = false;
|
@tracked isLoading = false;
|
||||||
|
|
||||||
|
get query() {
|
||||||
|
return this.args.query ?? this._internalQuery;
|
||||||
|
}
|
||||||
|
|
||||||
|
set query(value) {
|
||||||
|
this._internalQuery = value;
|
||||||
|
}
|
||||||
|
|
||||||
get showPopover() {
|
get showPopover() {
|
||||||
return this.isFocused && this.results.length > 0;
|
return this.isFocused && this.results.length > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@action
|
@action
|
||||||
handleInput(event) {
|
handleInput(event) {
|
||||||
this.query = event.target.value;
|
const value = event.target.value;
|
||||||
if (this.query.length < 2) {
|
this.query = value;
|
||||||
|
if (this.args.onQueryChange) {
|
||||||
|
this.args.onQueryChange(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value.length < 2) {
|
||||||
this.results = [];
|
this.results = [];
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.searchTask.perform();
|
this.searchTask.perform(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
searchTask = task({ restartable: true }, async () => {
|
searchTask = task({ restartable: true }, async (term) => {
|
||||||
await timeout(300);
|
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;
|
this.isLoading = true;
|
||||||
try {
|
try {
|
||||||
@@ -47,8 +64,20 @@ export default class SearchBoxComponent extends Component {
|
|||||||
if (this.mapUi.currentCenter) {
|
if (this.mapUi.currentCenter) {
|
||||||
({ lat, lon } = 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) {
|
} catch (e) {
|
||||||
console.error('Search failed', e);
|
console.error('Search failed', e);
|
||||||
this.results = [];
|
this.results = [];
|
||||||
@@ -80,7 +109,7 @@ export default class SearchBoxComponent extends Component {
|
|||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (!this.query) return;
|
if (!this.query) return;
|
||||||
|
|
||||||
let queryParams = { q: this.query, selected: null };
|
let queryParams = { q: this.query, selected: null, category: null };
|
||||||
|
|
||||||
if (this.mapUi.currentCenter) {
|
if (this.mapUi.currentCenter) {
|
||||||
const { lat, lon } = this.mapUi.currentCenter;
|
const { lat, lon } = this.mapUi.currentCenter;
|
||||||
@@ -94,7 +123,37 @@ export default class SearchBoxComponent extends Component {
|
|||||||
|
|
||||||
@action
|
@action
|
||||||
selectResult(place) {
|
selectResult(place) {
|
||||||
|
if (place.source === 'category') {
|
||||||
this.query = place.title;
|
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
|
this.results = []; // Hide popover
|
||||||
|
|
||||||
// If it has an OSM ID, go to place details
|
// If it has an OSM ID, go to place details
|
||||||
@@ -112,6 +171,7 @@ export default class SearchBoxComponent extends Component {
|
|||||||
lat: place.lat,
|
lat: place.lat,
|
||||||
lon: place.lon,
|
lon: place.lon,
|
||||||
selected: null,
|
selected: null,
|
||||||
|
category: null,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -119,10 +179,17 @@ export default class SearchBoxComponent extends Component {
|
|||||||
|
|
||||||
@action
|
@action
|
||||||
clear() {
|
clear() {
|
||||||
|
this.searchTask.cancelAll();
|
||||||
|
this.mapUi.stopLoading();
|
||||||
|
this.osm.cancelAll();
|
||||||
|
this.photon.cancelAll();
|
||||||
|
|
||||||
this.query = '';
|
this.query = '';
|
||||||
this.results = [];
|
this.results = [];
|
||||||
this.router.transitionTo('index'); // Or stay on current page?
|
if (this.args.onQueryChange) {
|
||||||
// Usually clear just clears the input.
|
this.args.onQueryChange('');
|
||||||
|
}
|
||||||
|
this.router.transitionTo('index');
|
||||||
}
|
}
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -150,7 +217,16 @@ export default class SearchBoxComponent extends Component {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<button type="submit" class="search-submit-btn" aria-label="Search">
|
<button type="submit" class="search-submit-btn" aria-label="Search">
|
||||||
|
{{#if
|
||||||
|
(or
|
||||||
|
(eq this.mapUi.loadingState.type "text")
|
||||||
|
(eq this.mapUi.loadingState.type "category")
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
<Icon @name="loading-ring" @size={{20}} />
|
||||||
|
{{else}}
|
||||||
<Icon @name="search" @size={{20}} @color="#5f6368" />
|
<Icon @name="search" @size={{20}} @color="#5f6368" />
|
||||||
|
{{/if}}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{{#if this.query}}
|
{{#if this.query}}
|
||||||
@@ -176,7 +252,11 @@ export default class SearchBoxComponent extends Component {
|
|||||||
{{on "click" (fn this.selectResult result)}}
|
{{on "click" (fn this.selectResult result)}}
|
||||||
>
|
>
|
||||||
<div class="result-icon">
|
<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>
|
||||||
<div class="result-info">
|
<div class="result-info">
|
||||||
<span class="result-title">{{result.title}}</span>
|
<span class="result-title">{{result.title}}</span>
|
||||||
|
|||||||
14
app/components/toast.gjs
Normal file
14
app/components/toast.gjs
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import Component from '@glimmer/component';
|
||||||
|
import { service } from '@ember/service';
|
||||||
|
|
||||||
|
export default class ToastComponent extends Component {
|
||||||
|
@service toast;
|
||||||
|
|
||||||
|
<template>
|
||||||
|
{{#if this.toast.isVisible}}
|
||||||
|
<div class="toast-notification">
|
||||||
|
{{this.toast.message}}
|
||||||
|
</div>
|
||||||
|
{{/if}}
|
||||||
|
</template>
|
||||||
|
}
|
||||||
@@ -1,10 +1,11 @@
|
|||||||
import Controller from '@ember/controller';
|
import Controller from '@ember/controller';
|
||||||
|
|
||||||
export default class SearchController extends Controller {
|
export default class SearchController extends Controller {
|
||||||
queryParams = ['lat', 'lon', 'q', 'selected'];
|
queryParams = ['lat', 'lon', 'q', 'selected', 'category'];
|
||||||
|
|
||||||
lat = null;
|
lat = null;
|
||||||
lon = null;
|
lon = null;
|
||||||
q = null;
|
q = null;
|
||||||
selected = null;
|
selected = null;
|
||||||
|
category = null;
|
||||||
}
|
}
|
||||||
|
|||||||
1
app/icons/270-ring.svg
Normal file
1
app/icons/270-ring.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" data-ember-extension="1"><path d="M10.72,19.9a8,8,0,0,1-6.5-9.79A7.77,7.77,0,0,1,10.4,4.16a8,8,0,0,1,9.49,6.52A1.54,1.54,0,0,0,21.38,12h.13a1.37,1.37,0,0,0,1.38-1.54,11,11,0,1,0-12.7,12.39A1.54,1.54,0,0,0,12,21.34h0A1.47,1.47,0,0,0,10.72,19.9Z"><animateTransform attributeName="transform" type="rotate" dur="0.75s" values="0 12 12;360 12 12" repeatCount="indefinite"/></path></svg>
|
||||||
|
After Width: | Height: | Size: 464 B |
10
app/routes/index.js
Normal file
10
app/routes/index.js
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import Route from '@ember/routing/route';
|
||||||
|
import { service } from '@ember/service';
|
||||||
|
|
||||||
|
export default class IndexRoute extends Route {
|
||||||
|
@service mapUi;
|
||||||
|
|
||||||
|
activate() {
|
||||||
|
this.mapUi.clearSearchResults();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -101,6 +101,23 @@ export default class PlaceRoute extends Route {
|
|||||||
return null;
|
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) {
|
serialize(model) {
|
||||||
// If the model is a saved bookmark, use its ID
|
// If the model is a saved bookmark, use its ID
|
||||||
if (model.id) {
|
if (model.id) {
|
||||||
|
|||||||
@@ -9,21 +9,68 @@ export default class SearchRoute extends Route {
|
|||||||
@service mapUi;
|
@service mapUi;
|
||||||
@service storage;
|
@service storage;
|
||||||
@service router;
|
@service router;
|
||||||
|
@service toast;
|
||||||
|
|
||||||
queryParams = {
|
queryParams = {
|
||||||
lat: { refreshModel: true },
|
lat: { refreshModel: true },
|
||||||
lon: { refreshModel: true },
|
lon: { refreshModel: true },
|
||||||
q: { refreshModel: true },
|
q: { refreshModel: true },
|
||||||
selected: { refreshModel: true },
|
selected: { refreshModel: true },
|
||||||
|
category: { refreshModel: true },
|
||||||
};
|
};
|
||||||
|
|
||||||
async model(params) {
|
async model(params) {
|
||||||
const lat = params.lat ? parseFloat(params.lat) : null;
|
const lat = params.lat ? parseFloat(params.lat) : null;
|
||||||
const lon = params.lon ? parseFloat(params.lon) : null;
|
const lon = params.lon ? parseFloat(params.lon) : null;
|
||||||
let pois = [];
|
let pois = [];
|
||||||
|
let loadingType = null;
|
||||||
|
let loadingValue = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Case 0: Category Search (category parameter present)
|
||||||
|
if (params.category && lat && lon) {
|
||||||
|
loadingType = 'category';
|
||||||
|
loadingValue = params.category;
|
||||||
|
this.mapUi.startLoading(loadingType, loadingValue);
|
||||||
|
|
||||||
|
// 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)
|
// Case 1: Text Search (q parameter present)
|
||||||
if (params.q) {
|
else if (params.q) {
|
||||||
|
loadingType = 'text';
|
||||||
|
loadingValue = params.q;
|
||||||
|
this.mapUi.startLoading(loadingType, loadingValue);
|
||||||
|
|
||||||
// Search with Photon (using lat/lon for bias if available)
|
// Search with Photon (using lat/lon for bias if available)
|
||||||
pois = await this.photon.search(params.q, lat, lon);
|
pois = await this.photon.search(params.q, lat, lon);
|
||||||
|
|
||||||
@@ -50,6 +97,7 @@ export default class SearchRoute extends Route {
|
|||||||
}
|
}
|
||||||
// Case 2: Nearby Search (lat/lon present, no q)
|
// Case 2: Nearby Search (lat/lon present, no q)
|
||||||
else if (lat && lon) {
|
else if (lat && lon) {
|
||||||
|
// Nearby search does NOT trigger loading state (pulse is used instead)
|
||||||
const searchRadius = 50; // Default radius
|
const searchRadius = 50; // Default radius
|
||||||
|
|
||||||
// Fetch POIs from Overpass
|
// Fetch POIs from Overpass
|
||||||
@@ -84,6 +132,11 @@ export default class SearchRoute extends Route {
|
|||||||
})
|
})
|
||||||
.sort((a, b) => a._distance - b._distance);
|
.sort((a, b) => a._distance - b._distance);
|
||||||
}
|
}
|
||||||
|
} finally {
|
||||||
|
if (loadingType && loadingValue) {
|
||||||
|
this.mapUi.stopLoading(loadingType, loadingValue);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Check if any of these are already bookmarked
|
// Check if any of these are already bookmarked
|
||||||
// We resolve them to the bookmark version if they exist
|
// We resolve them to the bookmark version if they exist
|
||||||
@@ -139,11 +192,20 @@ export default class SearchRoute extends Route {
|
|||||||
super.setupController(controller, model);
|
super.setupController(controller, model);
|
||||||
// Ensure pulse is stopped if we reach here
|
// Ensure pulse is stopped if we reach here
|
||||||
this.mapUi.stopSearch();
|
this.mapUi.stopSearch();
|
||||||
|
this.mapUi.setSearchResults(model);
|
||||||
|
|
||||||
|
// Store current search params to allow "Up" navigation from place details
|
||||||
|
const { q, category, lat, lon } = this.paramsFor('search');
|
||||||
|
this.mapUi.currentSearch = { q, category, lat, lon };
|
||||||
}
|
}
|
||||||
|
|
||||||
@action
|
@action
|
||||||
error() {
|
error(error, transition) {
|
||||||
this.mapUi.stopSearch();
|
this.mapUi.stopSearch();
|
||||||
return true; // Bubble error
|
this.toast.show('Search request failed. Please try again.');
|
||||||
|
if (transition) {
|
||||||
|
transition.abort();
|
||||||
|
}
|
||||||
|
return false; // Prevent bubble and stop transition
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,9 +8,13 @@ export default class MapUiService extends Service {
|
|||||||
@tracked creationCoordinates = null;
|
@tracked creationCoordinates = null;
|
||||||
@tracked returnToSearch = false;
|
@tracked returnToSearch = false;
|
||||||
@tracked currentCenter = null;
|
@tracked currentCenter = null;
|
||||||
|
@tracked currentBounds = null;
|
||||||
@tracked searchBoxHasFocus = false;
|
@tracked searchBoxHasFocus = false;
|
||||||
@tracked selectionOptions = {};
|
@tracked selectionOptions = {};
|
||||||
@tracked preventNextZoom = false;
|
@tracked preventNextZoom = false;
|
||||||
|
@tracked searchResults = [];
|
||||||
|
@tracked currentSearch = null;
|
||||||
|
@tracked loadingState = null;
|
||||||
|
|
||||||
selectPlace(place, options = {}) {
|
selectPlace(place, options = {}) {
|
||||||
this.selectedPlace = place;
|
this.selectedPlace = place;
|
||||||
@@ -23,6 +27,15 @@ export default class MapUiService extends Service {
|
|||||||
this.preventNextZoom = false;
|
this.preventNextZoom = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setSearchResults(results) {
|
||||||
|
this.searchResults = results || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
clearSearchResults() {
|
||||||
|
this.searchResults = [];
|
||||||
|
this.currentSearch = null;
|
||||||
|
}
|
||||||
|
|
||||||
startSearch() {
|
startSearch() {
|
||||||
this.isSearching = true;
|
this.isSearching = true;
|
||||||
this.isCreating = false;
|
this.isCreating = false;
|
||||||
@@ -54,4 +67,30 @@ export default class MapUiService extends Service {
|
|||||||
updateCenter(lat, lon) {
|
updateCenter(lat, lon) {
|
||||||
this.currentCenter = { lat, lon };
|
this.currentCenter = { lat, lon };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
updateBounds(bounds) {
|
||||||
|
this.currentBounds = bounds;
|
||||||
|
}
|
||||||
|
|
||||||
|
startLoading(type, value) {
|
||||||
|
this.loadingState = { type, value };
|
||||||
|
}
|
||||||
|
|
||||||
|
stopLoading(type = null, value = null) {
|
||||||
|
// If no arguments provided, force stop (legacy/cleanup)
|
||||||
|
if (!type && !value) {
|
||||||
|
this.loadingState = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only clear if the current state matches the request
|
||||||
|
// This prevents a previous search from clearing the state of a new search
|
||||||
|
if (
|
||||||
|
this.loadingState &&
|
||||||
|
this.loadingState.type === type &&
|
||||||
|
this.loadingState.value === value
|
||||||
|
) {
|
||||||
|
this.loadingState = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import Service, { service } from '@ember/service';
|
import Service, { service } from '@ember/service';
|
||||||
import { getLocalizedName, getPlaceType } from '../utils/osm';
|
import { getLocalizedName, getPlaceType } from '../utils/osm';
|
||||||
|
import { getCategoryById } from '../utils/poi-categories';
|
||||||
|
|
||||||
export default class OsmService extends Service {
|
export default class OsmService extends Service {
|
||||||
@service settings;
|
@service settings;
|
||||||
@@ -8,6 +9,13 @@ export default class OsmService extends Service {
|
|||||||
cachedResults = null;
|
cachedResults = null;
|
||||||
lastQueryKey = null;
|
lastQueryKey = null;
|
||||||
|
|
||||||
|
cancelAll() {
|
||||||
|
if (this.controller) {
|
||||||
|
this.controller.abort();
|
||||||
|
this.controller = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async getNearbyPois(lat, lon, radius = 50) {
|
async getNearbyPois(lat, lon, radius = 50) {
|
||||||
const queryKey = `${lat},${lon},${radius}`;
|
const queryKey = `${lat},${lon},${radius}`;
|
||||||
|
|
||||||
@@ -39,15 +47,26 @@ export default class OsmService extends Service {
|
|||||||
];
|
];
|
||||||
const typeKeysQuery = [`~"^(${typeKeys.join('|')})$"~".*"`];
|
const typeKeysQuery = [`~"^(${typeKeys.join('|')})$"~".*"`];
|
||||||
|
|
||||||
|
const negativeFilters = {
|
||||||
|
public_transport: ['stop_area', 'platform'],
|
||||||
|
};
|
||||||
|
|
||||||
|
const negativeFiltersQuery = Object.entries(negativeFilters)
|
||||||
|
.map(([key, values]) => {
|
||||||
|
const valueRegex = `^(${values.join('|')})$`;
|
||||||
|
return `["${key}"!~"${valueRegex}"]`;
|
||||||
|
})
|
||||||
|
.join('');
|
||||||
|
|
||||||
const query = `
|
const query = `
|
||||||
[out:json][timeout:25];
|
[out:json][timeout:25];
|
||||||
(
|
(
|
||||||
node(around:${radius},${lat},${lon})
|
node(around:${radius},${lat},${lon})
|
||||||
[${typeKeysQuery}][~"^name"~"."];
|
[${typeKeysQuery}]${negativeFiltersQuery}[~"^name"~"."];
|
||||||
way(around:${radius},${lat},${lon})
|
way(around:${radius},${lat},${lon})
|
||||||
[${typeKeysQuery}][~"^name"~"."];
|
[${typeKeysQuery}]${negativeFiltersQuery}[~"^name"~"."];
|
||||||
relation(around:${radius},${lat},${lon})
|
relation(around:${radius},${lat},${lon})
|
||||||
[${typeKeysQuery}][~"^name"~"."];
|
[${typeKeysQuery}]${negativeFiltersQuery}[~"^name"~"."];
|
||||||
);
|
);
|
||||||
out center;
|
out center;
|
||||||
`.trim();
|
`.trim();
|
||||||
@@ -76,6 +95,70 @@ out center;
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getCategoryPois(bounds, categoryId, lat, lon) {
|
||||||
|
const category = getCategoryById(categoryId);
|
||||||
|
if (!category || !bounds) return [];
|
||||||
|
|
||||||
|
const queryKey = lat && lon ? `cat:${categoryId}:${lat}:${lon}` : null;
|
||||||
|
|
||||||
|
if (queryKey && this.lastQueryKey === queryKey && this.cachedResults) {
|
||||||
|
console.debug('Returning cached category results for:', queryKey);
|
||||||
|
return this.cachedResults;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.controller) {
|
||||||
|
this.controller.abort();
|
||||||
|
}
|
||||||
|
this.controller = new AbortController();
|
||||||
|
const signal = this.controller.signal;
|
||||||
|
|
||||||
|
const { minLat, minLon, maxLat, maxLon } = bounds;
|
||||||
|
|
||||||
|
// Build the query parts for each filter string and type
|
||||||
|
const queryParts = [];
|
||||||
|
|
||||||
|
// Default types if not specified (legacy fallback)
|
||||||
|
const types = category.types || ['node', 'way', 'relation'];
|
||||||
|
|
||||||
|
category.filter.forEach((filterString) => {
|
||||||
|
types.forEach((type) => {
|
||||||
|
// We ensure we only fetch named POIs to reduce noise
|
||||||
|
queryParts.push(`${type}${filterString}[~"^name"~"."];`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const query = `
|
||||||
|
[out:json][timeout:25][bbox:${minLat},${minLon},${maxLat},${maxLon}];
|
||||||
|
(
|
||||||
|
${queryParts.join('\n ')}
|
||||||
|
);
|
||||||
|
out center;
|
||||||
|
`.trim();
|
||||||
|
|
||||||
|
const url = `${this.settings.overpassApi}?data=${encodeURIComponent(query)}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await this.fetchWithRetry(url, { signal });
|
||||||
|
if (!res.ok) throw new Error('Overpass request failed');
|
||||||
|
const data = await res.json();
|
||||||
|
const results = data.elements.map(this.normalizePoi);
|
||||||
|
|
||||||
|
if (queryKey) {
|
||||||
|
this.lastQueryKey = queryKey;
|
||||||
|
this.cachedResults = results;
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
} catch (e) {
|
||||||
|
if (e.name === 'AbortError') {
|
||||||
|
console.debug('Category search aborted');
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
console.error('Category search failed', e);
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
normalizePoi(poi) {
|
normalizePoi(poi) {
|
||||||
const tags = poi.tags || {};
|
const tags = poi.tags || {};
|
||||||
const type = getPlaceType(tags) || 'Point of Interest';
|
const type = getPlaceType(tags) || 'Point of Interest';
|
||||||
|
|||||||
@@ -5,6 +5,15 @@ import { humanizeOsmTag } from '../utils/format-text';
|
|||||||
export default class PhotonService extends Service {
|
export default class PhotonService extends Service {
|
||||||
@service settings;
|
@service settings;
|
||||||
|
|
||||||
|
controller = null;
|
||||||
|
|
||||||
|
cancelAll() {
|
||||||
|
if (this.controller) {
|
||||||
|
this.controller.abort();
|
||||||
|
this.controller = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
get baseUrl() {
|
get baseUrl() {
|
||||||
return this.settings.photonApi;
|
return this.settings.photonApi;
|
||||||
}
|
}
|
||||||
@@ -12,6 +21,12 @@ export default class PhotonService extends Service {
|
|||||||
async search(query, lat, lon, limit = 10) {
|
async search(query, lat, lon, limit = 10) {
|
||||||
if (!query || query.length < 2) return [];
|
if (!query || query.length < 2) return [];
|
||||||
|
|
||||||
|
if (this.controller) {
|
||||||
|
this.controller.abort();
|
||||||
|
}
|
||||||
|
this.controller = new AbortController();
|
||||||
|
const signal = this.controller.signal;
|
||||||
|
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
q: query,
|
q: query,
|
||||||
limit: String(limit),
|
limit: String(limit),
|
||||||
@@ -25,7 +40,7 @@ export default class PhotonService extends Service {
|
|||||||
const url = `${this.baseUrl}?${params.toString()}`;
|
const url = `${this.baseUrl}?${params.toString()}`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await this.fetchWithRetry(url);
|
const res = await this.fetchWithRetry(url, { signal });
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
throw new Error(`Photon request failed with status ${res.status}`);
|
throw new Error(`Photon request failed with status ${res.status}`);
|
||||||
}
|
}
|
||||||
@@ -35,6 +50,9 @@ export default class PhotonService extends Service {
|
|||||||
|
|
||||||
return data.features.map((f) => this.normalizeFeature(f));
|
return data.features.map((f) => this.normalizeFeature(f));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
if (e.name === 'AbortError') {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
console.error('Photon search error:', e);
|
console.error('Photon search error:', e);
|
||||||
// Return empty array on error so UI doesn't break
|
// Return empty array on error so UI doesn't break
|
||||||
return [];
|
return [];
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ export default class SettingsService extends Service {
|
|||||||
@tracked overpassApi = 'https://overpass-api.de/api/interpreter';
|
@tracked overpassApi = 'https://overpass-api.de/api/interpreter';
|
||||||
@tracked mapKinetic = true;
|
@tracked mapKinetic = true;
|
||||||
@tracked photonApi = 'https://photon.komoot.io/api/';
|
@tracked photonApi = 'https://photon.komoot.io/api/';
|
||||||
|
@tracked showQuickSearchButtons = true;
|
||||||
|
|
||||||
overpassApis = [
|
overpassApis = [
|
||||||
{
|
{
|
||||||
@@ -56,6 +57,13 @@ export default class SettingsService extends Service {
|
|||||||
this.mapKinetic = savedKinetic === 'true';
|
this.mapKinetic = savedKinetic === 'true';
|
||||||
}
|
}
|
||||||
// Default is true (initialized in class field)
|
// Default is true (initialized in class field)
|
||||||
|
|
||||||
|
const savedShowQuickSearch = localStorage.getItem(
|
||||||
|
'marco:show-quick-search'
|
||||||
|
);
|
||||||
|
if (savedShowQuickSearch !== null) {
|
||||||
|
this.showQuickSearchButtons = savedShowQuickSearch === 'true';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
updateOverpassApi(url) {
|
updateOverpassApi(url) {
|
||||||
@@ -68,6 +76,11 @@ export default class SettingsService extends Service {
|
|||||||
localStorage.setItem('marco:map-kinetic', String(enabled));
|
localStorage.setItem('marco:map-kinetic', String(enabled));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
updateShowQuickSearchButtons(enabled) {
|
||||||
|
this.showQuickSearchButtons = enabled;
|
||||||
|
localStorage.setItem('marco:show-quick-search', String(enabled));
|
||||||
|
}
|
||||||
|
|
||||||
updatePhotonApi(url) {
|
updatePhotonApi(url) {
|
||||||
this.photonApi = url;
|
this.photonApi = url;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import Service from '@ember/service';
|
import Service, { service } from '@ember/service';
|
||||||
import RemoteStorage from 'remotestoragejs';
|
import RemoteStorage from 'remotestoragejs';
|
||||||
import Places from '@remotestorage/module-places';
|
import Places from '@remotestorage/module-places';
|
||||||
import Widget from 'remotestorage-widget';
|
import Widget from 'remotestorage-widget';
|
||||||
@@ -7,8 +7,10 @@ import { getGeohashPrefixesInBbox } from '../utils/geohash-coverage';
|
|||||||
import { action } from '@ember/object';
|
import { action } from '@ember/object';
|
||||||
import { debounceTask } from 'ember-lifeline';
|
import { debounceTask } from 'ember-lifeline';
|
||||||
import Geohash from 'latlon-geohash';
|
import Geohash from 'latlon-geohash';
|
||||||
|
import { getLocalizedName } from '../utils/osm';
|
||||||
|
|
||||||
export default class StorageService extends Service {
|
export default class StorageService extends Service {
|
||||||
|
@service osm;
|
||||||
rs;
|
rs;
|
||||||
widget;
|
widget;
|
||||||
@tracked placesInView = [];
|
@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
|
@action
|
||||||
connect() {
|
connect() {
|
||||||
this.isWidgetOpen = true;
|
this.isWidgetOpen = true;
|
||||||
|
|||||||
21
app/services/toast.js
Normal file
21
app/services/toast.js
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import Service from '@ember/service';
|
||||||
|
import { tracked } from '@glimmer/tracking';
|
||||||
|
|
||||||
|
export default class ToastService extends Service {
|
||||||
|
@tracked message = null;
|
||||||
|
@tracked isVisible = false;
|
||||||
|
timeoutId = null;
|
||||||
|
|
||||||
|
show(message, duration = 3000) {
|
||||||
|
this.message = message;
|
||||||
|
this.isVisible = true;
|
||||||
|
|
||||||
|
if (this.timeoutId) {
|
||||||
|
clearTimeout(this.timeoutId);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.timeoutId = setTimeout(() => {
|
||||||
|
this.isVisible = false;
|
||||||
|
}, duration);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,9 +3,11 @@
|
|||||||
:root {
|
:root {
|
||||||
--default-list-color: #fc3;
|
--default-list-color: #fc3;
|
||||||
--hover-bg: #f8f9fa;
|
--hover-bg: #f8f9fa;
|
||||||
--sidebar-width: 360px;
|
--sidebar-width: 350px;
|
||||||
--link-color: #2a7fff;
|
--link-color: #2a7fff;
|
||||||
--link-color-visited: #6a4fbf;
|
--link-color-visited: #6a4fbf;
|
||||||
|
--marker-color-primary: #ea4335;
|
||||||
|
--marker-color-dark: #b31412;
|
||||||
}
|
}
|
||||||
|
|
||||||
html,
|
html,
|
||||||
@@ -70,27 +72,96 @@ body {
|
|||||||
right: 0;
|
right: 0;
|
||||||
height: 60px;
|
height: 60px;
|
||||||
padding: 0.5rem 1rem;
|
padding: 0.5rem 1rem;
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
z-index: 3000; /* Above sidebar (2000) and map */
|
z-index: 3000; /* Above sidebar (2000) and map */
|
||||||
pointer-events: none; /* Let clicks pass through to map where transparent */
|
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) {
|
@media (width <= 768px) {
|
||||||
.app-header {
|
.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-left,
|
||||||
.header-right {
|
.header-right,
|
||||||
pointer-events: auto; /* Re-enable clicks for buttons */
|
.header-center {
|
||||||
|
pointer-events: auto; /* Re-enable clicks */
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-left {
|
.header-left {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
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 {
|
.btn-press {
|
||||||
@@ -214,12 +285,12 @@ body {
|
|||||||
overflow: hidden; /* Ensure flex children are contained */
|
overflow: hidden; /* Ensure flex children are contained */
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-pane.sidebar {
|
.sidebar.app-menu-pane {
|
||||||
z-index: 3200; /* Higher than Places Sidebar (3100) */
|
z-index: 3200; /* Higher than Places Sidebar (3100) */
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (width <= 768px) {
|
@media (width <= 768px) {
|
||||||
.settings-pane.sidebar {
|
.sidebar.app-menu-pane {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
right: 0;
|
right: 0;
|
||||||
border-radius: 16px 16px 0 0;
|
border-radius: 16px 16px 0 0;
|
||||||
@@ -255,6 +326,10 @@ body {
|
|||||||
overscroll-behavior: contain;
|
overscroll-behavior: contain;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.app-menu-pane .sidebar-content {
|
||||||
|
padding-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
.app-menu {
|
.app-menu {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
@@ -340,11 +415,6 @@ body {
|
|||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-content details .link-list {
|
|
||||||
padding: 0;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes details-slide-down {
|
@keyframes details-slide-down {
|
||||||
from {
|
from {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
@@ -357,14 +427,6 @@ body {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-content details .link-list li {
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar-content details .link-list li:last-child {
|
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.edit-form {
|
.edit-form {
|
||||||
margin: -1rem;
|
margin: -1rem;
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
@@ -517,26 +579,6 @@ abbr[title] {
|
|||||||
text-decoration: underline dotted;
|
text-decoration: underline dotted;
|
||||||
}
|
}
|
||||||
|
|
||||||
.link-list {
|
|
||||||
list-style: none;
|
|
||||||
padding: 0;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.link-list li {
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.link-list a {
|
|
||||||
color: var(--link-color);
|
|
||||||
text-decoration: none;
|
|
||||||
font-size: 0.95rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.link-list a:hover {
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
|
|
||||||
.places-list {
|
.places-list {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
@@ -632,6 +674,12 @@ abbr[title] {
|
|||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.btn:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
.btn-outline {
|
.btn-outline {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: #333;
|
color: #333;
|
||||||
@@ -826,15 +874,15 @@ span.icon {
|
|||||||
.selected-pin {
|
.selected-pin {
|
||||||
width: 40px;
|
width: 40px;
|
||||||
height: 40px;
|
height: 40px;
|
||||||
color: #ea4335; /* Google Red */
|
color: var(--marker-color-primary);
|
||||||
filter: drop-shadow(0 4px 6px rgb(0 0 0 / 30%));
|
filter: drop-shadow(0 4px 6px rgb(0 0 0 / 30%));
|
||||||
}
|
}
|
||||||
|
|
||||||
.selected-pin svg {
|
.selected-pin svg {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
fill: #ea4335;
|
fill: var(--marker-color-primary);
|
||||||
stroke: #b31412; /* Darker red stroke */
|
stroke: var(--marker-color-dark);
|
||||||
stroke-width: 1;
|
stroke-width: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1213,3 +1261,96 @@ button.create-place {
|
|||||||
background: #eee;
|
background: #eee;
|
||||||
margin: 0.5rem 0;
|
margin: 0.5rem 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Category Chips */
|
||||||
|
.category-chips-scroll {
|
||||||
|
width: 100%;
|
||||||
|
overflow-x: auto;
|
||||||
|
|
||||||
|
/* Add padding for shadows */
|
||||||
|
padding: 4px 0;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
|
||||||
|
/* Hide scrollbar but keep functionality */
|
||||||
|
scrollbar-width: none; /* Firefox */
|
||||||
|
-ms-overflow-style: none; /* IE/Edge */
|
||||||
|
|
||||||
|
/* Remove top margin as spacing is handled by grid/layout */
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-chips-scroll::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-chips-container {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
|
||||||
|
/* Padding on sides so first/last chip isn't flush with screen edge */
|
||||||
|
padding: 0 4px;
|
||||||
|
width: max-content; /* Ensure it scrolls */
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-chip {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
background: white;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 16px; /* Pill shape */
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: #333;
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
box-shadow: 0 1px 3px rgb(0 0 0 / 10%);
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-chip:hover {
|
||||||
|
background: var(--hover-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-chip:active {
|
||||||
|
background: #eee;
|
||||||
|
}
|
||||||
|
|
||||||
|
.category-chip:disabled {
|
||||||
|
opacity: 0.75;
|
||||||
|
cursor: not-allowed;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Toast Notification */
|
||||||
|
.toast-notification {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 2rem;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
background-color: rgb(51 51 51 / 85%);
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
color: white;
|
||||||
|
padding: 0.75rem 1.5rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
z-index: 9999;
|
||||||
|
box-shadow: 0 4px 12px rgb(0 0 0 / 15%);
|
||||||
|
animation: fade-in-up 0.3s ease-out forwards;
|
||||||
|
text-align: center;
|
||||||
|
max-width: 90%;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 500;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fade-in-up {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translate(-50%, 1rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translate(-50%, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { pageTitle } from 'ember-page-title';
|
|||||||
import Map from '#components/map';
|
import Map from '#components/map';
|
||||||
import AppHeader from '#components/app-header';
|
import AppHeader from '#components/app-header';
|
||||||
import AppMenu from '#components/app-menu/index';
|
import AppMenu from '#components/app-menu/index';
|
||||||
|
import Toast from '#components/toast';
|
||||||
import { service } from '@ember/service';
|
import { service } from '@ember/service';
|
||||||
import { tracked } from '@glimmer/tracking';
|
import { tracked } from '@glimmer/tracking';
|
||||||
import { action } from '@ember/object';
|
import { action } from '@ember/object';
|
||||||
@@ -89,6 +90,8 @@ export default class ApplicationComponent extends Component {
|
|||||||
<AppMenu @onClose={{this.closeAppMenu}} />
|
<AppMenu @onClose={{this.closeAppMenu}} />
|
||||||
{{/if}}
|
{{/if}}
|
||||||
|
|
||||||
|
<Toast />
|
||||||
|
|
||||||
{{outlet}}
|
{{outlet}}
|
||||||
</template>
|
</template>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -77,9 +77,11 @@ export default class PlaceTemplate extends Component {
|
|||||||
navigateBack(place) {
|
navigateBack(place) {
|
||||||
// The sidebar calls this with null when "Back" is clicked.
|
// The sidebar calls this with null when "Back" is clicked.
|
||||||
if (place === null) {
|
if (place === null) {
|
||||||
// If we came from search results, go back in history
|
// If we have an active search context, return to it (UP navigation)
|
||||||
if (this.mapUi.returnToSearch) {
|
if (this.mapUi.returnToSearch && this.mapUi.currentSearch) {
|
||||||
window.history.back();
|
this.router.transitionTo('search', {
|
||||||
|
queryParams: this.mapUi.currentSearch,
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
// Otherwise just close the sidebar (return to map index)
|
// Otherwise just close the sidebar (return to map index)
|
||||||
this.router.transitionTo('index');
|
this.router.transitionTo('index');
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ export default class SearchTemplate extends Component {
|
|||||||
selectPlace(place) {
|
selectPlace(place) {
|
||||||
if (place) {
|
if (place) {
|
||||||
this.mapUi.returnToSearch = true;
|
this.mapUi.returnToSearch = true;
|
||||||
|
// We don't need to manually set currentSearch here because
|
||||||
|
// it was already set in the route's setupController
|
||||||
this.router.transitionTo('place', place);
|
this.router.transitionTo('place', place);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
import arrowLeft from 'feather-icons/dist/icons/arrow-left.svg?raw';
|
|
||||||
import activity from 'feather-icons/dist/icons/activity.svg?raw';
|
import activity from 'feather-icons/dist/icons/activity.svg?raw';
|
||||||
|
import arrowLeft from 'feather-icons/dist/icons/arrow-left.svg?raw';
|
||||||
import bookmark from 'feather-icons/dist/icons/bookmark.svg?raw';
|
import bookmark from 'feather-icons/dist/icons/bookmark.svg?raw';
|
||||||
import checkSquare from 'feather-icons/dist/icons/check-square.svg?raw';
|
import checkSquare from 'feather-icons/dist/icons/check-square.svg?raw';
|
||||||
|
import cigaretteWithSmokeCurl from '@waysidemapping/pinhead/dist/icons/cigarette_with_smoke_curl.svg?raw';
|
||||||
import clock from 'feather-icons/dist/icons/clock.svg?raw';
|
import clock from 'feather-icons/dist/icons/clock.svg?raw';
|
||||||
import edit from 'feather-icons/dist/icons/edit.svg?raw';
|
import edit from 'feather-icons/dist/icons/edit.svg?raw';
|
||||||
|
import eyeglasses from '@waysidemapping/pinhead/dist/icons/eyeglasses.svg?raw';
|
||||||
import facebook from 'feather-icons/dist/icons/facebook.svg?raw';
|
import facebook from 'feather-icons/dist/icons/facebook.svg?raw';
|
||||||
|
import fancyMirrorWithReflectionAndStars from '@waysidemapping/pinhead/dist/icons/fancy_mirror_with_reflection_and_stars.svg?raw';
|
||||||
|
import familyRestroomSymbol from '@waysidemapping/pinhead/dist/icons/family_restroom_symbol.svg?raw';
|
||||||
import gift from 'feather-icons/dist/icons/gift.svg?raw';
|
import gift from 'feather-icons/dist/icons/gift.svg?raw';
|
||||||
import globe from 'feather-icons/dist/icons/globe.svg?raw';
|
import globe from 'feather-icons/dist/icons/globe.svg?raw';
|
||||||
import heart from 'feather-icons/dist/icons/heart.svg?raw';
|
import heart from 'feather-icons/dist/icons/heart.svg?raw';
|
||||||
@@ -13,55 +17,221 @@ import info from 'feather-icons/dist/icons/info.svg?raw';
|
|||||||
import instagram from 'feather-icons/dist/icons/instagram.svg?raw';
|
import instagram from 'feather-icons/dist/icons/instagram.svg?raw';
|
||||||
import logIn from 'feather-icons/dist/icons/log-in.svg?raw';
|
import logIn from 'feather-icons/dist/icons/log-in.svg?raw';
|
||||||
import logOut from 'feather-icons/dist/icons/log-out.svg?raw';
|
import logOut from 'feather-icons/dist/icons/log-out.svg?raw';
|
||||||
|
import lowriseBuilding from '@waysidemapping/pinhead/dist/icons/lowrise_building.svg?raw';
|
||||||
import mail from 'feather-icons/dist/icons/mail.svg?raw';
|
import mail from 'feather-icons/dist/icons/mail.svg?raw';
|
||||||
import map from 'feather-icons/dist/icons/map.svg?raw';
|
import map from 'feather-icons/dist/icons/map.svg?raw';
|
||||||
import mapPin from 'feather-icons/dist/icons/map-pin.svg?raw';
|
import mapPin from 'feather-icons/dist/icons/map-pin.svg?raw';
|
||||||
import menu from 'feather-icons/dist/icons/menu.svg?raw';
|
import menu from 'feather-icons/dist/icons/menu.svg?raw';
|
||||||
import navigation from 'feather-icons/dist/icons/navigation.svg?raw';
|
import navigation from 'feather-icons/dist/icons/navigation.svg?raw';
|
||||||
|
import needleAndSpoolOfThread from '@waysidemapping/pinhead/dist/icons/needle_and_spool_of_thread.svg?raw';
|
||||||
import phone from 'feather-icons/dist/icons/phone.svg?raw';
|
import phone from 'feather-icons/dist/icons/phone.svg?raw';
|
||||||
import plus from 'feather-icons/dist/icons/plus.svg?raw';
|
import plus from 'feather-icons/dist/icons/plus.svg?raw';
|
||||||
import server from 'feather-icons/dist/icons/server.svg?raw';
|
|
||||||
import search from 'feather-icons/dist/icons/search.svg?raw';
|
import search from 'feather-icons/dist/icons/search.svg?raw';
|
||||||
|
import server from 'feather-icons/dist/icons/server.svg?raw';
|
||||||
import settings from 'feather-icons/dist/icons/settings.svg?raw';
|
import settings from 'feather-icons/dist/icons/settings.svg?raw';
|
||||||
import target from 'feather-icons/dist/icons/target.svg?raw';
|
import target from 'feather-icons/dist/icons/target.svg?raw';
|
||||||
import user from 'feather-icons/dist/icons/user.svg?raw';
|
import user from 'feather-icons/dist/icons/user.svg?raw';
|
||||||
|
import villageBuildings from '@waysidemapping/pinhead/dist/icons/village_buildings.svg?raw';
|
||||||
import x from 'feather-icons/dist/icons/x.svg?raw';
|
import x from 'feather-icons/dist/icons/x.svg?raw';
|
||||||
import zap from 'feather-icons/dist/icons/zap.svg?raw';
|
import zap from 'feather-icons/dist/icons/zap.svg?raw';
|
||||||
|
import loadingRing from '../icons/270-ring.svg?raw';
|
||||||
|
|
||||||
|
import angelfish from '@waysidemapping/pinhead/dist/icons/angelfish.svg?raw';
|
||||||
|
import barbell from '@waysidemapping/pinhead/dist/icons/barbell.svg?raw';
|
||||||
|
import banknote from '@waysidemapping/pinhead/dist/icons/banknote.svg?raw';
|
||||||
|
import badgeShieldWithFire from '@waysidemapping/pinhead/dist/icons/badge_shield_with_fire.svg?raw';
|
||||||
|
import beachUmbrellaInGround from '@waysidemapping/pinhead/dist/icons/beach_umbrella_in_ground.svg?raw';
|
||||||
|
import beerMugWithFoam from '@waysidemapping/pinhead/dist/icons/beer_mug_with_foam.svg?raw';
|
||||||
|
import burgerAndDrinkCupWithStraw from '@waysidemapping/pinhead/dist/icons/burger_and_drink_cup_with_straw.svg?raw';
|
||||||
|
import bus from '@waysidemapping/pinhead/dist/icons/bus.svg?raw';
|
||||||
|
import boxingGloveUp from '@waysidemapping/pinhead/dist/icons/boxing_glove_up.svg?raw';
|
||||||
|
import camera from '@waysidemapping/pinhead/dist/icons/camera.svg?raw';
|
||||||
|
import classicalBuilding from '@waysidemapping/pinhead/dist/icons/classical_building.svg?raw';
|
||||||
|
import classicalBuildingWithDomeAndFlag from '@waysidemapping/pinhead/dist/icons/classical_building_with_dome_and_flag.svg?raw';
|
||||||
|
import classicalBuildingWithFlag from '@waysidemapping/pinhead/dist/icons/classical_building_with_flag.svg?raw';
|
||||||
|
import commercialBuilding from '@waysidemapping/pinhead/dist/icons/commercial_building.svg?raw';
|
||||||
|
import clothesHanger from '@waysidemapping/pinhead/dist/icons/clothes_hanger.svg?raw';
|
||||||
|
import cleaver from '@waysidemapping/pinhead/dist/icons/cleaver.svg?raw';
|
||||||
|
import cloth from '@waysidemapping/pinhead/dist/icons/cloth.svg?raw';
|
||||||
|
import cocktail from '@waysidemapping/pinhead/dist/icons/cocktail.svg?raw';
|
||||||
|
import coffeeBean from '@waysidemapping/pinhead/dist/icons/coffee_bean.svg?raw';
|
||||||
|
import comedyMaskAndTragedyMask from '@waysidemapping/pinhead/dist/icons/comedy_mask_and_tragedy_mask.svg?raw';
|
||||||
|
import croissant from '@waysidemapping/pinhead/dist/icons/croissant.svg?raw';
|
||||||
|
import cupAndSaucer from '@waysidemapping/pinhead/dist/icons/cup_and_saucer.svg?raw';
|
||||||
|
import donut from '@waysidemapping/pinhead/dist/icons/donut.svg?raw';
|
||||||
|
import film from '@waysidemapping/pinhead/dist/icons/film.svg?raw';
|
||||||
|
import fingernailPolished from '@waysidemapping/pinhead/dist/icons/fingernail_polished.svg?raw';
|
||||||
|
import fish from '@waysidemapping/pinhead/dist/icons/fish.svg?raw';
|
||||||
|
import flagCheckered from '@waysidemapping/pinhead/dist/icons/flag_checkered.svg?raw';
|
||||||
|
import flowerBouquet from '@waysidemapping/pinhead/dist/icons/flower_bouquet.svg?raw';
|
||||||
|
import fort from '@waysidemapping/pinhead/dist/icons/fort.svg?raw';
|
||||||
|
import forkAndKnife from '@waysidemapping/pinhead/dist/icons/fork_and_knife.svg?raw';
|
||||||
|
import gravestone from '@waysidemapping/pinhead/dist/icons/gravestone.svg?raw';
|
||||||
|
import grecianVase from '@waysidemapping/pinhead/dist/icons/grecian_vase.svg?raw';
|
||||||
|
import greekCross from '@waysidemapping/pinhead/dist/icons/greek_cross.svg?raw';
|
||||||
|
import iceCreamOnCone from '@waysidemapping/pinhead/dist/icons/ice_cream_on_cone.svg?raw';
|
||||||
|
import jewel from '@waysidemapping/pinhead/dist/icons/jewel.svg?raw';
|
||||||
|
import marketStall from '@waysidemapping/pinhead/dist/icons/market_stall.svg?raw';
|
||||||
|
import memorialStoneWithInscription from '@waysidemapping/pinhead/dist/icons/memorial_stone_with_inscription.svg?raw';
|
||||||
|
import mobilePhoneWithKeypadAndAntenna from '@waysidemapping/pinhead/dist/icons/mobile_phone_with_keypad_and_antenna.svg?raw';
|
||||||
|
import molarTooth from '@waysidemapping/pinhead/dist/icons/molar_tooth.svg?raw';
|
||||||
|
import openBook from '@waysidemapping/pinhead/dist/icons/open_book.svg?raw';
|
||||||
|
import palace from '@waysidemapping/pinhead/dist/icons/palace.svg?raw';
|
||||||
|
import personCricketBattingAtCricketBall from '@waysidemapping/pinhead/dist/icons/person_cricket_batting_at_cricket_ball.svg?raw';
|
||||||
|
import personBoardingTramWithDestinationDisplayAndPantographOnTramTrack from '@waysidemapping/pinhead/dist/icons/person_boarding_tram_with_destination_display_and_pantograph_on_tram_track.svg?raw';
|
||||||
|
import personJockeyingRacehorse from '@waysidemapping/pinhead/dist/icons/person_jockeying_racehorse.svg?raw';
|
||||||
|
import personPlayingTennis from '@waysidemapping/pinhead/dist/icons/person_playing_tennis.svg?raw';
|
||||||
|
import personRunning from '@waysidemapping/pinhead/dist/icons/person_running.svg?raw';
|
||||||
|
import personSleepingInBed from '@waysidemapping/pinhead/dist/icons/person_sleeping_in_bed.svg?raw';
|
||||||
|
import personSwimmingInWater from '@waysidemapping/pinhead/dist/icons/person_swimming_in_water.svg?raw';
|
||||||
|
import personSwingingGolfClub from '@waysidemapping/pinhead/dist/icons/person_swinging_golf_club.svg?raw';
|
||||||
|
import plantInRaisedPlanter from '@waysidemapping/pinhead/dist/icons/plant_in_raised_planter.svg?raw';
|
||||||
|
import placeOfWorshipBuilding from '@waysidemapping/pinhead/dist/icons/place_of_worship_building.svg?raw';
|
||||||
|
import playStructureWithSlide from '@waysidemapping/pinhead/dist/icons/play_structure_with_slide.svg?raw';
|
||||||
|
import policeOfficerWithStopArm from '@waysidemapping/pinhead/dist/icons/police_officer_with_stop_arm.svg?raw';
|
||||||
|
import planeTopRight from '@waysidemapping/pinhead/dist/icons/plane_top_right.svg?raw';
|
||||||
|
import roundStructureWithFlag from '@waysidemapping/pinhead/dist/icons/round_structure_with_flag.svg?raw';
|
||||||
|
import sailingShipInWater from '@waysidemapping/pinhead/dist/icons/sailing_ship_in_water.svg?raw';
|
||||||
|
import scissorsOpen from '@waysidemapping/pinhead/dist/icons/scissors_open.svg?raw';
|
||||||
|
import shipwreckInWater from '@waysidemapping/pinhead/dist/icons/shipwreck_in_water.svg?raw';
|
||||||
|
import shoppingBag from '@waysidemapping/pinhead/dist/icons/shopping_bag.svg?raw';
|
||||||
|
import shoppingBasket from '@waysidemapping/pinhead/dist/icons/shopping_basket.svg?raw';
|
||||||
|
import tableTennisPaddle from '@waysidemapping/pinhead/dist/icons/table_tennis_paddle.svg?raw';
|
||||||
|
import tattooMachine from '@waysidemapping/pinhead/dist/icons/tattoo_machine.svg?raw';
|
||||||
|
import toolbox from '@waysidemapping/pinhead/dist/icons/toolbox.svg?raw';
|
||||||
|
import treeAndBenchWithBackrest from '@waysidemapping/pinhead/dist/icons/tree_and_bench_with_backrest.svg?raw';
|
||||||
|
import shoppingCart from '@waysidemapping/pinhead/dist/icons/shopping_cart.svg?raw';
|
||||||
|
import wallHangingWithMountainsAndSun from '@waysidemapping/pinhead/dist/icons/wall_hanging_with_mountains_and_sun.svg?raw';
|
||||||
|
import womensAndMensRestroomSymbol from '@waysidemapping/pinhead/dist/icons/womens_and_mens_restroom_symbol.svg?raw';
|
||||||
|
|
||||||
import wikipedia from '../icons/wikipedia.svg?raw';
|
import wikipedia from '../icons/wikipedia.svg?raw';
|
||||||
|
import parkingP from '@waysidemapping/pinhead/dist/icons/parking_p.svg?raw';
|
||||||
|
|
||||||
const ICONS = {
|
const ICONS = {
|
||||||
'arrow-left': arrowLeft,
|
|
||||||
activity,
|
activity,
|
||||||
|
angelfish,
|
||||||
|
'arrow-left': arrowLeft,
|
||||||
|
barbell,
|
||||||
|
banknote,
|
||||||
|
'badge-shield-with-fire': badgeShieldWithFire,
|
||||||
|
'beach-umbrella-in-ground': beachUmbrellaInGround,
|
||||||
|
'beer-mug-with-foam': beerMugWithFoam,
|
||||||
bookmark,
|
bookmark,
|
||||||
|
'boxing-glove-up': boxingGloveUp,
|
||||||
|
'burger-and-drink-cup-with-straw': burgerAndDrinkCupWithStraw,
|
||||||
|
bus,
|
||||||
|
camera,
|
||||||
'check-square': checkSquare,
|
'check-square': checkSquare,
|
||||||
|
'cigarette-with-smoke-curl': cigaretteWithSmokeCurl,
|
||||||
|
'classical-building': classicalBuilding,
|
||||||
|
'classical-building-with-dome-and-flag': classicalBuildingWithDomeAndFlag,
|
||||||
|
'classical-building-with-flag': classicalBuildingWithFlag,
|
||||||
|
'commercial-building': commercialBuilding,
|
||||||
|
'clothes-hanger': clothesHanger,
|
||||||
|
cleaver,
|
||||||
|
cloth,
|
||||||
|
cocktail,
|
||||||
clock,
|
clock,
|
||||||
|
'coffee-bean': coffeeBean,
|
||||||
|
'comedy-mask-and-tragedy-mask': comedyMaskAndTragedyMask,
|
||||||
|
croissant,
|
||||||
|
'cup-and-saucer': cupAndSaucer,
|
||||||
|
donut,
|
||||||
edit,
|
edit,
|
||||||
|
eyeglasses,
|
||||||
facebook,
|
facebook,
|
||||||
|
'fancy-mirror-with-reflection-and-stars': fancyMirrorWithReflectionAndStars,
|
||||||
|
'family-restroom-symbol': familyRestroomSymbol,
|
||||||
|
film,
|
||||||
|
'fingernail-polished': fingernailPolished,
|
||||||
|
fish,
|
||||||
|
'flag-checkered': flagCheckered,
|
||||||
|
'flower-bouquet': flowerBouquet,
|
||||||
|
'fork-and-knife': forkAndKnife,
|
||||||
|
fort,
|
||||||
gift,
|
gift,
|
||||||
globe,
|
globe,
|
||||||
|
gravestone,
|
||||||
|
'grecian-vase': grecianVase,
|
||||||
|
'greek-cross': greekCross,
|
||||||
heart,
|
heart,
|
||||||
home,
|
home,
|
||||||
|
'ice-cream-on-cone': iceCreamOnCone,
|
||||||
info,
|
info,
|
||||||
instagram,
|
instagram,
|
||||||
|
jewel,
|
||||||
'log-in': logIn,
|
'log-in': logIn,
|
||||||
'log-out': logOut,
|
'log-out': logOut,
|
||||||
|
'lowrise-building': lowriseBuilding,
|
||||||
mail,
|
mail,
|
||||||
map,
|
map,
|
||||||
'map-pin': mapPin,
|
'map-pin': mapPin,
|
||||||
|
'market-stall': marketStall,
|
||||||
|
'memorial-stone-with-inscription': memorialStoneWithInscription,
|
||||||
menu,
|
menu,
|
||||||
|
'mobile-phone-with-keypad-and-antenna': mobilePhoneWithKeypadAndAntenna,
|
||||||
|
'molar-tooth': molarTooth,
|
||||||
navigation,
|
navigation,
|
||||||
|
'needle-and-spool-of-thread': needleAndSpoolOfThread,
|
||||||
|
'open-book': openBook,
|
||||||
|
palace,
|
||||||
|
'person-cricket-batting-at-cricket-ball': personCricketBattingAtCricketBall,
|
||||||
|
'person-boarding-tram-with-destination-display-and-pantograph-on-tram-track':
|
||||||
|
personBoardingTramWithDestinationDisplayAndPantographOnTramTrack,
|
||||||
|
'person-jockeying-racehorse': personJockeyingRacehorse,
|
||||||
|
'person-playing-tennis': personPlayingTennis,
|
||||||
|
'person-running': personRunning,
|
||||||
|
'person-sleeping-in-bed': personSleepingInBed,
|
||||||
|
'person-swimming-in-water': personSwimmingInWater,
|
||||||
|
'person-swinging-golf-club': personSwingingGolfClub,
|
||||||
phone,
|
phone,
|
||||||
|
'plane-top-right': planeTopRight,
|
||||||
|
'plant-in-raised-planter': plantInRaisedPlanter,
|
||||||
|
'place-of-worship-building': placeOfWorshipBuilding,
|
||||||
|
'play-structure-with-slide': playStructureWithSlide,
|
||||||
|
'police-officer-with-stop-arm': policeOfficerWithStopArm,
|
||||||
plus,
|
plus,
|
||||||
server,
|
'round-structure-with-flag': roundStructureWithFlag,
|
||||||
|
'sailing-ship-in-water': sailingShipInWater,
|
||||||
|
'scissors-open': scissorsOpen,
|
||||||
|
'shipwreck-in-water': shipwreckInWater,
|
||||||
|
'shopping-bag': shoppingBag,
|
||||||
search,
|
search,
|
||||||
|
server,
|
||||||
settings,
|
settings,
|
||||||
|
'shopping-basket': shoppingBasket,
|
||||||
|
'shopping-cart': shoppingCart,
|
||||||
|
'table-tennis-paddle': tableTennisPaddle,
|
||||||
|
'tattoo-machine': tattooMachine,
|
||||||
|
toolbox,
|
||||||
target,
|
target,
|
||||||
|
'tree-and-bench-with-backrest': treeAndBenchWithBackrest,
|
||||||
user,
|
user,
|
||||||
|
'village-buildings': villageBuildings,
|
||||||
|
'wall-hanging-with-mountains-and-sun': wallHangingWithMountainsAndSun,
|
||||||
|
'womens-and-mens-restroom-symbol': womensAndMensRestroomSymbol,
|
||||||
wikipedia,
|
wikipedia,
|
||||||
|
parking_p: parkingP,
|
||||||
x,
|
x,
|
||||||
zap,
|
zap,
|
||||||
|
'loading-ring': loadingRing,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const FILLED_ICONS = [
|
||||||
|
'fork-and-knife',
|
||||||
|
'wikipedia',
|
||||||
|
'cup-and-saucer',
|
||||||
|
'coffee-bean',
|
||||||
|
'shopping-basket',
|
||||||
|
'camera',
|
||||||
|
'person-sleeping-in-bed',
|
||||||
|
'loading-ring',
|
||||||
|
];
|
||||||
|
|
||||||
export function getIcon(name) {
|
export function getIcon(name) {
|
||||||
return ICONS[name];
|
return ICONS[name];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isIconFilled(name) {
|
||||||
|
return FILLED_ICONS.includes(name);
|
||||||
|
}
|
||||||
|
|||||||
222
app/utils/osm-icons.js
Normal file
222
app/utils/osm-icons.js
Normal file
@@ -0,0 +1,222 @@
|
|||||||
|
import { getIcon } from './icons';
|
||||||
|
|
||||||
|
// Rules for mapping OSM tags to icons.
|
||||||
|
// Rules are evaluated in order. The first rule where all specified tags match is used.
|
||||||
|
export const POI_ICON_RULES = [
|
||||||
|
// Specific Cuisine
|
||||||
|
{ tags: { cuisine: 'donut' }, icon: 'donut' },
|
||||||
|
{ tags: { cuisine: 'doughnut' }, icon: 'donut' },
|
||||||
|
{ tags: { cuisine: 'coffee_shop' }, icon: 'coffee-bean' },
|
||||||
|
{ tags: { cuisine: 'coffee' }, icon: 'coffee-bean' },
|
||||||
|
|
||||||
|
// General Amenity/Shop Types
|
||||||
|
{ tags: { amenity: 'ice_cream' }, icon: 'ice-cream-on-cone' },
|
||||||
|
{ tags: { cuisine: 'ice_cream' }, icon: 'ice-cream-on-cone' },
|
||||||
|
{ tags: { shop: 'ice_cream' }, icon: 'ice-cream-on-cone' },
|
||||||
|
|
||||||
|
{ tags: { amenity: 'cafe' }, icon: 'cup-and-saucer' },
|
||||||
|
{ tags: { amenity: 'restaurant' }, icon: 'fork-and-knife' },
|
||||||
|
{ tags: { amenity: 'fast_food' }, icon: 'burger-and-drink-cup-with-straw' },
|
||||||
|
{ tags: { amenity: 'pub' }, icon: 'beer-mug-with-foam' },
|
||||||
|
{ tags: { amenity: 'bar' }, icon: 'cocktail' },
|
||||||
|
{ tags: { amenity: 'food_court' }, icon: 'fork-and-knife' },
|
||||||
|
{ tags: { amenity: 'childcare' }, icon: 'family-restroom-symbol' },
|
||||||
|
{ tags: { amenity: 'community_centre' }, icon: 'family-restroom-symbol' },
|
||||||
|
{ tags: { amenity: 'social_centre' }, icon: 'family-restroom-symbol' },
|
||||||
|
{ tags: { amenity: 'social_facility' }, icon: 'family-restroom-symbol' },
|
||||||
|
|
||||||
|
{ tags: { amenity: 'bank' }, icon: 'banknote' },
|
||||||
|
{ tags: { amenity: 'place_of_worship' }, icon: 'place-of-worship-building' },
|
||||||
|
{ tags: { amenity: 'fire_station' }, icon: 'badge-shield-with-fire' },
|
||||||
|
{ tags: { amenity: 'police' }, icon: 'police-officer-with-stop-arm' },
|
||||||
|
{ tags: { amenity: 'toilets' }, icon: 'womens-and-mens-restroom-symbol' },
|
||||||
|
{ tags: { amenity: 'school' }, icon: 'open-book' },
|
||||||
|
|
||||||
|
{ tags: { shop: 'coffee' }, icon: 'coffee-bean' },
|
||||||
|
{ tags: { shop: 'tea' }, icon: 'coffee-bean' },
|
||||||
|
{ tags: { shop: 'pastry' }, icon: 'donut' },
|
||||||
|
|
||||||
|
// Shopping
|
||||||
|
{ tags: { shop: 'supermarket' }, icon: 'shopping-cart' },
|
||||||
|
{ tags: { shop: 'convenience' }, icon: 'shopping-basket' },
|
||||||
|
{ tags: { shop: 'grocery' }, icon: 'shopping-basket' },
|
||||||
|
{ tags: { shop: 'greengrocer' }, icon: 'shopping-basket' },
|
||||||
|
{ tags: { shop: 'bakery' }, icon: 'croissant' },
|
||||||
|
{ tags: { shop: 'butcher' }, icon: 'cleaver' },
|
||||||
|
{ tags: { shop: 'seafood' }, icon: 'fish' },
|
||||||
|
{ tags: { shop: 'deli' }, icon: 'shopping-basket' },
|
||||||
|
{ tags: { shop: 'clothes' }, icon: 'clothes-hanger' },
|
||||||
|
{ tags: { shop: 'clothing' }, icon: 'clothes-hanger' },
|
||||||
|
{ tags: { shop: 'hairdresser' }, icon: 'scissors-open' },
|
||||||
|
{ tags: { shop: 'optician' }, icon: 'eyeglasses' },
|
||||||
|
{ tags: { shop: 'fabric' }, icon: 'cloth' },
|
||||||
|
{ tags: { shop: 'flea_market' }, icon: 'market-stall' },
|
||||||
|
{ tags: { shop: 'kiosk' }, icon: 'shopping-basket' },
|
||||||
|
{ tags: { shop: 'leather' }, icon: 'shopping-bag' },
|
||||||
|
{ tags: { shop: 'tailor' }, icon: 'needle-and-spool-of-thread' },
|
||||||
|
{ tags: { shop: 'jewelry' }, icon: 'jewel' },
|
||||||
|
{ tags: { shop: 'jewellery' }, icon: 'jewel' },
|
||||||
|
{ tags: { shop: 'tobacco' }, icon: 'cigarette-with-smoke-curl' },
|
||||||
|
{ tags: { shop: 'cannabis' }, icon: 'cigarette-with-smoke-curl' },
|
||||||
|
{ tags: { shop: 'florist' }, icon: 'flower-bouquet' },
|
||||||
|
{ tags: { shop: 'garden_centre' }, icon: 'plant-in-raised-planter' },
|
||||||
|
{ tags: { shop: 'estate_agent' }, icon: 'village-buildings' },
|
||||||
|
{
|
||||||
|
tags: { shop: 'mobile_phone' },
|
||||||
|
icon: 'mobile-phone-with-keypad-and-antenna',
|
||||||
|
},
|
||||||
|
{ tags: { beauty: 'nails' }, icon: 'fingernail-polished' },
|
||||||
|
{ tags: { shop: 'tattoo' }, icon: 'tattoo-machine' },
|
||||||
|
{
|
||||||
|
tags: { shop: 'beauty' },
|
||||||
|
icon: 'fancy-mirror-with-reflection-and-stars',
|
||||||
|
},
|
||||||
|
{ tags: { craft: 'tailor' }, icon: 'needle-and-spool-of-thread' },
|
||||||
|
{ tags: { office: 'estate_agent' }, icon: 'village-buildings' },
|
||||||
|
{ tags: { office: true }, icon: 'commercial-building' },
|
||||||
|
{ tags: { craft: true }, icon: 'toolbox' },
|
||||||
|
{ tags: { shop: true }, icon: 'shopping-bag' },
|
||||||
|
|
||||||
|
// Natural
|
||||||
|
{ tags: { natural: 'beach' }, icon: 'beach-umbrella-in-ground' },
|
||||||
|
{ tags: { leisure: 'park' }, icon: 'tree-and-bench-with-backrest' },
|
||||||
|
{ tags: { leisure: 'playground' }, icon: 'play-structure-with-slide' },
|
||||||
|
|
||||||
|
// Transport
|
||||||
|
{ tags: { aeroway: 'aerodrome' }, icon: 'plane-top-right' },
|
||||||
|
{ tags: { aeroway: 'heliport' }, icon: 'plane-top-right' },
|
||||||
|
{ tags: { aeroway: 'helipad' }, icon: 'plane-top-right' },
|
||||||
|
{ tags: { highway: 'bus_stop' }, icon: 'bus' },
|
||||||
|
{ tags: { bus: true }, icon: 'bus' },
|
||||||
|
{
|
||||||
|
tags: { railway: 'tram_stop' },
|
||||||
|
icon: 'person-boarding-tram-with-destination-display-and-pantograph-on-tram-track',
|
||||||
|
},
|
||||||
|
|
||||||
|
// Tourism
|
||||||
|
{ tags: { tourism: 'museum' }, icon: 'classical-building' },
|
||||||
|
{ tags: { tourism: 'gallery' }, icon: 'wall-hanging-with-mountains-and-sun' },
|
||||||
|
{ tags: { tourism: 'aquarium' }, icon: 'angelfish' },
|
||||||
|
{ tags: { tourism: 'theme_park' }, icon: 'camera' },
|
||||||
|
{ tags: { tourism: 'attraction' }, icon: 'camera' },
|
||||||
|
{ tags: { tourism: 'viewpoint' }, icon: 'camera' },
|
||||||
|
{ tags: { tourism: 'zoo' }, icon: 'camera' },
|
||||||
|
{ tags: { tourism: 'artwork' }, icon: 'camera' },
|
||||||
|
{ tags: { amenity: 'cinema' }, icon: 'film' },
|
||||||
|
{ tags: { amenity: 'theatre' }, icon: 'camera' },
|
||||||
|
{ tags: { amenity: 'arts_centre' }, icon: 'comedy-mask-and-tragedy-mask' },
|
||||||
|
{ tags: { amenity: 'arts_center' }, icon: 'comedy-mask-and-tragedy-mask' },
|
||||||
|
|
||||||
|
// Historic
|
||||||
|
{ tags: { historic: 'fort' }, icon: 'fort' },
|
||||||
|
{ tags: { historic: 'castle' }, icon: 'palace' },
|
||||||
|
{ tags: { historic: 'building' }, icon: 'classical-building-with-flag' },
|
||||||
|
{ tags: { historic: 'archaeological_site' }, icon: 'grecian-vase' },
|
||||||
|
{ tags: { historic: 'memorial' }, icon: 'memorial-stone-with-inscription' },
|
||||||
|
{ tags: { historic: 'tomb' }, icon: 'gravestone' },
|
||||||
|
{
|
||||||
|
tags: { historic: 'monument' },
|
||||||
|
icon: 'classical-building-with-dome-and-flag',
|
||||||
|
},
|
||||||
|
{ tags: { historic: 'ship' }, icon: 'sailing-ship-in-water' },
|
||||||
|
{ tags: { historic: 'wreck' }, icon: 'shipwreck-in-water' },
|
||||||
|
{ tags: { historic: 'ruins' }, icon: 'camera' },
|
||||||
|
{ tags: { historic: 'ruin' }, icon: 'camera' },
|
||||||
|
{ tags: { historic: 'yes' }, icon: 'camera' },
|
||||||
|
|
||||||
|
// Accommodation
|
||||||
|
{ tags: { tourism: 'hotel' }, icon: 'person-sleeping-in-bed' },
|
||||||
|
{ tags: { tourism: 'hostel' }, icon: 'person-sleeping-in-bed' },
|
||||||
|
{ tags: { tourism: 'motel' }, icon: 'person-sleeping-in-bed' },
|
||||||
|
{ tags: { tourism: 'guest_house' }, icon: 'person-sleeping-in-bed' },
|
||||||
|
|
||||||
|
// Sports / Motorsports
|
||||||
|
{ tags: { sport: 'motor' }, icon: 'flag-checkered' },
|
||||||
|
{ tags: { sport: 'karting' }, icon: 'flag-checkered' },
|
||||||
|
{ tags: { sport: 'motocross' }, icon: 'flag-checkered' },
|
||||||
|
{
|
||||||
|
tags: { sport: 'cricket' },
|
||||||
|
icon: 'person-cricket-batting-at-cricket-ball',
|
||||||
|
},
|
||||||
|
{ tags: { sport: 'boxing' }, icon: 'boxing-glove-up' },
|
||||||
|
{ tags: { sport: 'martial_arts' }, icon: 'boxing-glove-up' },
|
||||||
|
{ tags: { sport: 'tennis' }, icon: 'person-playing-tennis' },
|
||||||
|
{ tags: { sport: 'squash' }, icon: 'person-playing-tennis' },
|
||||||
|
{ tags: { sport: 'padel' }, icon: 'person-playing-tennis' },
|
||||||
|
{ tags: { sport: 'table_tennis' }, icon: 'table-tennis-paddle' },
|
||||||
|
{ tags: { leisure: 'water_park' }, icon: 'person-swimming-in-water' },
|
||||||
|
{ tags: { sport: 'swimming' }, icon: 'person-swimming-in-water' },
|
||||||
|
{ tags: { sport: 'golf' }, icon: 'person-swinging-golf-club' },
|
||||||
|
{ tags: { leisure: 'golf_course' }, icon: 'person-swinging-golf-club' },
|
||||||
|
{ tags: { sport: 'horse_racing' }, icon: 'person-jockeying-racehorse' },
|
||||||
|
{ tags: { sport: 'fitness' }, icon: 'barbell' },
|
||||||
|
{ tags: { sport: 'fitness_centre' }, icon: 'barbell' },
|
||||||
|
{ tags: { leisure: 'fitness_centre' }, icon: 'barbell' },
|
||||||
|
|
||||||
|
{ tags: { sport: 'stadium' }, icon: 'round-structure-with-flag' },
|
||||||
|
{ tags: { leisure: 'stadium' }, icon: 'round-structure-with-flag' },
|
||||||
|
{ tags: { leisure: 'sports_centre' }, icon: 'person-running' },
|
||||||
|
{ tags: { leisure: 'pitch' }, icon: 'person-running' },
|
||||||
|
{ tags: { sport: true }, icon: 'person-running' },
|
||||||
|
|
||||||
|
// Healthcare
|
||||||
|
{ tags: { amenity: 'dentist' }, icon: 'molar-tooth' },
|
||||||
|
{ tags: { healthcare: 'dentist' }, icon: 'molar-tooth' },
|
||||||
|
{ tags: { healthcare: true }, icon: 'greek-cross' },
|
||||||
|
|
||||||
|
// Parking
|
||||||
|
{ tags: { amenity: 'parking' }, icon: 'parking_p' },
|
||||||
|
|
||||||
|
// Buildings
|
||||||
|
{ tags: { building: 'commercial' }, icon: 'commercial-building' },
|
||||||
|
{ tags: { building: 'apartments' }, icon: 'lowrise-building' },
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finds the appropriate icon name based on the place's OSM tags.
|
||||||
|
* @param {Object} tags - The OSM tags of the place.
|
||||||
|
* @returns {string|null} - The name of the icon or null if no match found.
|
||||||
|
*/
|
||||||
|
export function getIconNameForTags(tags) {
|
||||||
|
if (!tags) return null;
|
||||||
|
|
||||||
|
for (const rule of POI_ICON_RULES) {
|
||||||
|
let match = true;
|
||||||
|
for (const [key, expectedValue] of Object.entries(rule.tags)) {
|
||||||
|
const tagValue = tags[key];
|
||||||
|
if (!tagValue) {
|
||||||
|
match = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for exact match or if value is in a semicolon-separated list
|
||||||
|
// e.g. "donut;coffee_shop"
|
||||||
|
const values = tagValue.split(';').map((v) => v.trim());
|
||||||
|
|
||||||
|
// If expectedValue is boolean true, any value is a match
|
||||||
|
if (expectedValue === true) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!values.includes(expectedValue)) {
|
||||||
|
match = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (match) {
|
||||||
|
return rule.icon;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the raw SVG string for the icon corresponding to the given tags.
|
||||||
|
* @param {Object} tags - The OSM tags.
|
||||||
|
* @returns {string|null} - The raw SVG string or null.
|
||||||
|
*/
|
||||||
|
export function getIconSvgForTags(tags) {
|
||||||
|
const iconName = getIconNameForTags(tags);
|
||||||
|
if (!iconName) return null;
|
||||||
|
return getIcon(iconName);
|
||||||
|
}
|
||||||
65
app/utils/poi-categories.js
Normal file
65
app/utils/poi-categories.js
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
// This configuration defines the "Quick Search" categories available in the UI.
|
||||||
|
//
|
||||||
|
// Structure:
|
||||||
|
// - id: The URL slug used for routing (e.g. ?category=restaurants)
|
||||||
|
// - label: The human-readable name displayed in the UI
|
||||||
|
// - icon: The icon name (must be registered in app/utils/icons.js)
|
||||||
|
// - filter: An array of Overpass QL query parts.
|
||||||
|
// - Each string in the array is an independent query condition.
|
||||||
|
// - Multiple strings act as an OR condition (union of results).
|
||||||
|
|
||||||
|
export const POI_CATEGORIES = [
|
||||||
|
{
|
||||||
|
id: 'restaurants',
|
||||||
|
label: 'Restaurants',
|
||||||
|
icon: 'fork-and-knife',
|
||||||
|
filter: [
|
||||||
|
'["amenity"~"^(restaurant|fast_food|food_court|pub|cafe)$"]["cuisine"!~"coffee"]',
|
||||||
|
],
|
||||||
|
types: ['node', 'way'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'coffee',
|
||||||
|
label: 'Coffee',
|
||||||
|
icon: 'cup-and-saucer',
|
||||||
|
filter: [
|
||||||
|
'["amenity"~"^(cafe|ice_cream)$"]',
|
||||||
|
'["shop"~"^(coffee|tea)$"]',
|
||||||
|
'["cuisine"~"coffee_shop"]',
|
||||||
|
],
|
||||||
|
types: ['node', 'way'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'groceries',
|
||||||
|
label: 'Groceries',
|
||||||
|
icon: 'shopping-basket',
|
||||||
|
filter: [
|
||||||
|
'["shop"~"^(supermarket|convenience|grocery|greengrocer|bakery|butcher|deli|farm|seafood)$"]',
|
||||||
|
],
|
||||||
|
types: ['node', 'way'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'things-to-do',
|
||||||
|
label: 'Things to do',
|
||||||
|
icon: 'camera',
|
||||||
|
filter: [
|
||||||
|
'["tourism"~"^(museum|gallery|attraction|viewpoint|zoo|theme_park|aquarium|artwork)$"]',
|
||||||
|
'["amenity"~"^(cinema|theatre|arts_centre|planetarium)$"]',
|
||||||
|
'["leisure"~"^(sports_centre|stadium|water_park)$"]',
|
||||||
|
'["historic"]',
|
||||||
|
'["shop"="flea_market"]',
|
||||||
|
],
|
||||||
|
types: ['node', 'way', 'relation'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'accommodation',
|
||||||
|
label: 'Hotels',
|
||||||
|
icon: 'person-sleeping-in-bed',
|
||||||
|
filter: ['["tourism"~"^(hotel|hostel|motel)$"]'],
|
||||||
|
types: ['node', 'way', 'relation'],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function getCategoryById(id) {
|
||||||
|
return POI_CATEGORIES.find((c) => c.id === id);
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "marco",
|
"name": "marco",
|
||||||
"version": "1.15.1",
|
"version": "1.17.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "Unhosted maps app",
|
"description": "Unhosted maps app",
|
||||||
"repository": {
|
"repository": {
|
||||||
@@ -102,6 +102,7 @@
|
|||||||
"edition": "octane"
|
"edition": "octane"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@waysidemapping/pinhead": "^15.17.0",
|
||||||
"ember-concurrency": "^5.2.0",
|
"ember-concurrency": "^5.2.0",
|
||||||
"ember-lifeline": "^7.0.0"
|
"ember-lifeline": "^7.0.0"
|
||||||
}
|
}
|
||||||
|
|||||||
8
pnpm-lock.yaml
generated
8
pnpm-lock.yaml
generated
@@ -8,6 +8,9 @@ importers:
|
|||||||
|
|
||||||
.:
|
.:
|
||||||
dependencies:
|
dependencies:
|
||||||
|
'@waysidemapping/pinhead':
|
||||||
|
specifier: ^15.17.0
|
||||||
|
version: 15.17.0
|
||||||
ember-concurrency:
|
ember-concurrency:
|
||||||
specifier: ^5.2.0
|
specifier: ^5.2.0
|
||||||
version: 5.2.0(@babel/core@7.28.6)
|
version: 5.2.0(@babel/core@7.28.6)
|
||||||
@@ -1651,6 +1654,9 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
'@warp-drive/core': 5.8.1
|
'@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':
|
'@xmldom/xmldom@0.8.11':
|
||||||
resolution: {integrity: sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw==}
|
resolution: {integrity: sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw==}
|
||||||
engines: {node: '>=10.0.0'}
|
engines: {node: '>=10.0.0'}
|
||||||
@@ -7239,6 +7245,8 @@ snapshots:
|
|||||||
- '@glint/template'
|
- '@glint/template'
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
|
'@waysidemapping/pinhead@15.17.0': {}
|
||||||
|
|
||||||
'@xmldom/xmldom@0.8.11': {}
|
'@xmldom/xmldom@0.8.11': {}
|
||||||
|
|
||||||
abbrev@1.1.1: {}
|
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-DM7YMuyX.js
Normal file
2
release/assets/main-DM7YMuyX.js
Normal file
File diff suppressed because one or more lines are too long
1
release/assets/main-OLSOzTKA.css
Normal file
1
release/assets/main-OLSOzTKA.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-TileColor" content="#F6E9A6">
|
||||||
<meta name="msapplication-TileImage" content="/icons/icon-144.png">
|
<meta name="msapplication-TileImage" content="/icons/icon-144.png">
|
||||||
|
|
||||||
<script type="module" crossorigin src="/assets/main-B0kgA7Vd.js"></script>
|
<script type="module" crossorigin src="/assets/main-DM7YMuyX.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/main-CiVfjn0D.css">
|
<link rel="stylesheet" crossorigin href="/assets/main-OLSOzTKA.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
182
tests/acceptance/map-search-reset-test.js
Normal file
182
tests/acceptance/map-search-reset-test.js
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
import { module, test } from 'qunit';
|
||||||
|
import { visit, currentURL, waitFor, triggerEvent } from '@ember/test-helpers';
|
||||||
|
import { setupApplicationTest } from 'marco/tests/helpers';
|
||||||
|
import Service from '@ember/service';
|
||||||
|
import sinon from 'sinon';
|
||||||
|
|
||||||
|
module('Acceptance | map search reset', function (hooks) {
|
||||||
|
setupApplicationTest(hooks);
|
||||||
|
|
||||||
|
hooks.beforeEach(function () {
|
||||||
|
// Seed localStorage with a high zoom level to ensure map is interactive
|
||||||
|
const highZoomState = {
|
||||||
|
center: [13.4, 52.5],
|
||||||
|
zoom: 18,
|
||||||
|
};
|
||||||
|
window.localStorage.setItem(
|
||||||
|
'marco:map-view',
|
||||||
|
JSON.stringify(highZoomState)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Stub window.fetch using Sinon
|
||||||
|
// We want to intercept map style requests and let everything else through
|
||||||
|
this.fetchStub = sinon.stub(window, 'fetch');
|
||||||
|
|
||||||
|
this.fetchStub.callsFake(async (input, init) => {
|
||||||
|
let url = input;
|
||||||
|
if (typeof input === 'object' && input !== null && 'url' in input) {
|
||||||
|
url = input.url;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
typeof url === 'string' &&
|
||||||
|
url.includes('tiles.openfreemap.org/styles/liberty')
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
json: async () => ({
|
||||||
|
version: 8,
|
||||||
|
name: 'Liberty',
|
||||||
|
sources: {
|
||||||
|
openmaptiles: {
|
||||||
|
type: 'vector',
|
||||||
|
url: 'https://tiles.openfreemap.org/planet',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
layers: [
|
||||||
|
{
|
||||||
|
id: 'background',
|
||||||
|
type: 'background',
|
||||||
|
paint: {
|
||||||
|
'background-color': '#123456',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
glyphs:
|
||||||
|
'https://tiles.openfreemap.org/fonts/{fontstack}/{range}.pbf',
|
||||||
|
sprite: 'https://tiles.openfreemap.org/sprites/liberty',
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pass through to the original implementation
|
||||||
|
return this.fetchStub.wrappedMethod(input, init);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
hooks.afterEach(function () {
|
||||||
|
window.localStorage.removeItem('marco:map-view');
|
||||||
|
// Restore the original fetch
|
||||||
|
this.fetchStub.restore();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('clicking the map clears the category search parameter', async function (assert) {
|
||||||
|
// Mock OSM Service
|
||||||
|
class MockOsmService extends Service {
|
||||||
|
async getCategoryPois() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
title: 'Cafe Test',
|
||||||
|
lat: 52.52,
|
||||||
|
lon: 13.405,
|
||||||
|
osmId: '123',
|
||||||
|
osmType: 'N',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
async getNearbyPois() {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.owner.register('service:osm', MockOsmService);
|
||||||
|
|
||||||
|
// Mock Storage
|
||||||
|
this.owner.register(
|
||||||
|
'service:storage',
|
||||||
|
class extends Service {
|
||||||
|
rs = { on: () => {} };
|
||||||
|
placesInView = [];
|
||||||
|
savedPlaces = [];
|
||||||
|
loadPlacesInBounds() {
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
findPlaceById() {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// 1. Visit a category search URL
|
||||||
|
await visit('/search?category=coffee&lat=52.52&lon=13.405');
|
||||||
|
|
||||||
|
assert.dom('.sidebar-header').includesText('Results');
|
||||||
|
assert.ok(
|
||||||
|
currentURL().includes('category=coffee'),
|
||||||
|
'URL should have category param'
|
||||||
|
);
|
||||||
|
|
||||||
|
// 2. Click the map (First click closes sidebar)
|
||||||
|
await waitFor('canvas', { timeout: 2000 });
|
||||||
|
|
||||||
|
const canvas = document.querySelector('canvas');
|
||||||
|
if (canvas) {
|
||||||
|
// First Click (Close Sidebar)
|
||||||
|
await triggerEvent(canvas, 'pointerdown', {
|
||||||
|
clientX: 200,
|
||||||
|
clientY: 200,
|
||||||
|
button: 0,
|
||||||
|
isPrimary: true,
|
||||||
|
});
|
||||||
|
await triggerEvent(canvas, 'pointerup', {
|
||||||
|
clientX: 200,
|
||||||
|
clientY: 200,
|
||||||
|
button: 0,
|
||||||
|
isPrimary: true,
|
||||||
|
});
|
||||||
|
await triggerEvent(canvas, 'click', {
|
||||||
|
clientX: 200,
|
||||||
|
clientY: 200,
|
||||||
|
bubbles: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Wait for transition to index
|
||||||
|
await new Promise((r) => setTimeout(r, 500));
|
||||||
|
assert.strictEqual(
|
||||||
|
currentURL(),
|
||||||
|
'/',
|
||||||
|
'Should have transitioned to index (closed sidebar)'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Second Click (Start new search)
|
||||||
|
// Click slightly differently to ensure fresh event
|
||||||
|
await triggerEvent(canvas, 'pointerdown', {
|
||||||
|
clientX: 250,
|
||||||
|
clientY: 250,
|
||||||
|
button: 0,
|
||||||
|
isPrimary: true,
|
||||||
|
});
|
||||||
|
await triggerEvent(canvas, 'pointerup', {
|
||||||
|
clientX: 250,
|
||||||
|
clientY: 250,
|
||||||
|
button: 0,
|
||||||
|
isPrimary: true,
|
||||||
|
});
|
||||||
|
await triggerEvent(canvas, 'click', {
|
||||||
|
clientX: 250,
|
||||||
|
clientY: 250,
|
||||||
|
bubbles: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Wait for transition
|
||||||
|
await new Promise((r) => setTimeout(r, 1000));
|
||||||
|
|
||||||
|
const newUrl = currentURL();
|
||||||
|
assert.notOk(
|
||||||
|
newUrl.includes('category=coffee'),
|
||||||
|
`New URL ${newUrl} should not contain category param`
|
||||||
|
);
|
||||||
|
assert.ok(newUrl.includes('/search'), 'Should be on search route');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -64,11 +64,9 @@ module('Acceptance | navigation', function (hooks) {
|
|||||||
this.owner.register('service:storage', MockStorageService);
|
this.owner.register('service:storage', MockStorageService);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('navigating from search results to place and back uses history', async function (assert) {
|
test('navigating from search results to place and back returns to search', async function (assert) {
|
||||||
const mapUi = this.owner.lookup('service:map-ui');
|
const mapUi = this.owner.lookup('service:map-ui');
|
||||||
const backStub = sinon.stub(window.history, 'back');
|
|
||||||
|
|
||||||
try {
|
|
||||||
await visit('/search?lat=1&lon=1');
|
await visit('/search?lat=1&lon=1');
|
||||||
assert.strictEqual(currentURL(), '/search?lat=1&lon=1');
|
assert.strictEqual(currentURL(), '/search?lat=1&lon=1');
|
||||||
|
|
||||||
@@ -79,10 +77,11 @@ module('Acceptance | navigation', function (hooks) {
|
|||||||
// Click the back button in the sidebar
|
// Click the back button in the sidebar
|
||||||
await click('.back-btn');
|
await click('.back-btn');
|
||||||
|
|
||||||
assert.true(backStub.calledOnce, 'window.history.back() was called');
|
assert.strictEqual(
|
||||||
} finally {
|
currentURL(),
|
||||||
backStub.restore();
|
'/search?lat=1&lon=1',
|
||||||
}
|
'Returned to search results'
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('closing the sidebar resets the returnToSearch flag', async function (assert) {
|
test('closing the sidebar resets the returnToSearch flag', async function (assert) {
|
||||||
|
|||||||
136
tests/acceptance/search-loading-test.js
Normal file
136
tests/acceptance/search-loading-test.js
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
import { module, test } from 'qunit';
|
||||||
|
import { visit, click, fillIn, currentURL } from '@ember/test-helpers';
|
||||||
|
import { setupApplicationTest } from 'marco/tests/helpers';
|
||||||
|
import Service from '@ember/service';
|
||||||
|
import { Promise } from 'rsvp';
|
||||||
|
|
||||||
|
class MockPhotonService extends Service {
|
||||||
|
cancelAll() {}
|
||||||
|
|
||||||
|
async search(query) {
|
||||||
|
// Simulate network delay
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
if (query === 'slow') {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
title: 'Test Place',
|
||||||
|
lat: 1,
|
||||||
|
lon: 1,
|
||||||
|
osmId: '123',
|
||||||
|
osmType: 'node',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class MockOsmService extends Service {
|
||||||
|
cancelAll() {}
|
||||||
|
|
||||||
|
async getCategoryPois(bounds, category) {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
|
if (category === 'slow_category') {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
async getNearbyPois() {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module('Acceptance | search loading', function (hooks) {
|
||||||
|
setupApplicationTest(hooks);
|
||||||
|
|
||||||
|
hooks.beforeEach(function () {
|
||||||
|
this.owner.register('service:photon', MockPhotonService);
|
||||||
|
this.owner.register('service:osm', MockOsmService);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('search shows loading indicator but nearby search does not', async function (assert) {
|
||||||
|
const mapUi = this.owner.lookup('service:map-ui');
|
||||||
|
|
||||||
|
// 1. Text Search
|
||||||
|
// Start a search and check for loading state immediately
|
||||||
|
const searchPromise = visit('/search?q=slow');
|
||||||
|
|
||||||
|
// We can't easily check the DOM mid-transition in acceptance tests without complicated helpers,
|
||||||
|
// so we check the service state which drives the UI.
|
||||||
|
// Wait a tiny bit for the route to start processing
|
||||||
|
await new Promise((r) => setTimeout(r, 10));
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
mapUi.loadingState,
|
||||||
|
{ type: 'text', value: 'slow' },
|
||||||
|
'Loading state is set for text search'
|
||||||
|
);
|
||||||
|
|
||||||
|
await searchPromise;
|
||||||
|
assert.strictEqual(
|
||||||
|
mapUi.loadingState,
|
||||||
|
null,
|
||||||
|
'Loading state is cleared after text search'
|
||||||
|
);
|
||||||
|
|
||||||
|
// 2. Category Search
|
||||||
|
const catPromise = visit('/search?category=slow_category&lat=1&lon=1');
|
||||||
|
await new Promise((r) => setTimeout(r, 10));
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
mapUi.loadingState,
|
||||||
|
{ type: 'category', value: 'slow_category' },
|
||||||
|
'Loading state is set for category search'
|
||||||
|
);
|
||||||
|
|
||||||
|
await catPromise;
|
||||||
|
assert.strictEqual(
|
||||||
|
mapUi.loadingState,
|
||||||
|
null,
|
||||||
|
'Loading state is cleared after category search'
|
||||||
|
);
|
||||||
|
|
||||||
|
// 3. Nearby Search
|
||||||
|
await visit('/search?lat=1&lon=1');
|
||||||
|
assert.strictEqual(
|
||||||
|
mapUi.loadingState,
|
||||||
|
null,
|
||||||
|
'Loading state is NOT set for nearby search'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('clearing search stops loading indicator', async function (assert) {
|
||||||
|
const mapUi = this.owner.lookup('service:map-ui');
|
||||||
|
|
||||||
|
// 1. Start from index
|
||||||
|
await visit('/');
|
||||||
|
|
||||||
|
// 2. Type "slow" to trigger autocomplete (which is async)
|
||||||
|
await fillIn('.search-input', 'slow');
|
||||||
|
|
||||||
|
// 3. Submit search to trigger route loading
|
||||||
|
click('.search-submit-btn'); // Intentionally no await to not block on transition
|
||||||
|
|
||||||
|
// Wait for loading state to activate
|
||||||
|
await new Promise((r) => setTimeout(r, 100));
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
mapUi.loadingState,
|
||||||
|
{ type: 'text', value: 'slow' },
|
||||||
|
'Loading state is set'
|
||||||
|
);
|
||||||
|
|
||||||
|
// 4. Click the clear button (should be visible since input has value)
|
||||||
|
await click('.search-clear-btn');
|
||||||
|
|
||||||
|
// Verify loading state is cleared immediately
|
||||||
|
assert.strictEqual(
|
||||||
|
mapUi.loadingState,
|
||||||
|
null,
|
||||||
|
'Loading state is cleared immediately after clicking clear'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Verify we are back on index (or at least query is gone)
|
||||||
|
assert.strictEqual(currentURL(), '/', 'Navigated to index');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -155,4 +155,119 @@ module('Acceptance | search', function (hooks) {
|
|||||||
assert.dom('.places-list li').exists({ count: 1 });
|
assert.dom('.places-list li').exists({ count: 1 });
|
||||||
assert.dom('.places-list li .place-name').hasText('My Secret Base');
|
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');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('search error handling prevents opening empty panel and shows toast', async function (assert) {
|
||||||
|
// Mock Osm Service to throw an error
|
||||||
|
class MockOsmService extends Service {
|
||||||
|
async getCategoryPois() {
|
||||||
|
throw new Error('Overpass request failed');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.owner.register('service:osm', MockOsmService);
|
||||||
|
|
||||||
|
class MockStorageService extends Service {
|
||||||
|
savedPlaces = [];
|
||||||
|
findPlaceById() {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
isPlaceSaved() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
rs = { on: () => {} };
|
||||||
|
placesInView = [];
|
||||||
|
loadPlacesInBounds() {
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.owner.register('service:storage', MockStorageService);
|
||||||
|
|
||||||
|
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('/');
|
||||||
|
|
||||||
|
try {
|
||||||
|
await visit('/search?category=coffee&lat=52.52&lon=13.405');
|
||||||
|
} catch {
|
||||||
|
// Aborted transition throws, which is expected
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.dom('.toast-notification').exists('Toast should be visible');
|
||||||
|
assert
|
||||||
|
.dom('.toast-notification')
|
||||||
|
.hasText('Search request failed. Please try again.');
|
||||||
|
assert.dom('.places-sidebar').doesNotExist('Results panel should not open');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,13 +1,29 @@
|
|||||||
import { module, test } from 'qunit';
|
import { module, test } from 'qunit';
|
||||||
import { setupRenderingTest } from 'marco/tests/helpers';
|
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 AppHeader from 'marco/components/app-header';
|
||||||
|
import Service from '@ember/service';
|
||||||
|
|
||||||
module('Integration | Component | app-header', function (hooks) {
|
module('Integration | Component | app-header', function (hooks) {
|
||||||
setupRenderingTest(hooks);
|
setupRenderingTest(hooks);
|
||||||
|
|
||||||
test('it renders the search box', async function (assert) {
|
test('it renders the search box', async function (assert) {
|
||||||
this.noop = () => {};
|
this.noop = () => {};
|
||||||
|
|
||||||
|
class MockPhotonService extends Service {}
|
||||||
|
class MockRouterService extends Service {}
|
||||||
|
class MockMapUiService extends Service {}
|
||||||
|
class MockMapService extends Service {}
|
||||||
|
class MockSettingsService extends Service {
|
||||||
|
showQuickSearchButtons = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
this.owner.register('service:settings', MockSettingsService);
|
||||||
|
|
||||||
await render(
|
await render(
|
||||||
<template><AppHeader @onToggleMenu={{this.noop}} /></template>
|
<template><AppHeader @onToggleMenu={{this.noop}} /></template>
|
||||||
);
|
);
|
||||||
@@ -16,4 +32,43 @@ module('Integration | Component | app-header', function (hooks) {
|
|||||||
assert.dom('.search-box').exists('Search box is present in the header');
|
assert.dom('.search-box').exists('Search box is present in the header');
|
||||||
assert.dom('.menu-btn-integrated').exists('Menu button is integrated');
|
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 {}
|
||||||
|
class MockSettingsService extends Service {
|
||||||
|
showQuickSearchButtons = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
this.owner.register('service:settings', MockSettingsService);
|
||||||
|
|
||||||
|
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 }));
|
.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));
|
||||||
|
|
||||||
assert.verifySteps([
|
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']);
|
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');
|
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'
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
68
tests/unit/services/map-ui-test.js
Normal file
68
tests/unit/services/map-ui-test.js
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import { module, test } from 'qunit';
|
||||||
|
import { setupTest } from 'marco/tests/helpers';
|
||||||
|
|
||||||
|
module('Unit | Service | map-ui', function (hooks) {
|
||||||
|
setupTest(hooks);
|
||||||
|
|
||||||
|
test('it handles loading state correctly', function (assert) {
|
||||||
|
let service = this.owner.lookup('service:map-ui');
|
||||||
|
|
||||||
|
// Initial state
|
||||||
|
assert.strictEqual(
|
||||||
|
service.loadingState,
|
||||||
|
null,
|
||||||
|
'loadingState starts as null'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Start loading search A
|
||||||
|
service.startLoading('search', 'A');
|
||||||
|
assert.deepEqual(
|
||||||
|
service.loadingState,
|
||||||
|
{ type: 'search', value: 'A' },
|
||||||
|
'loadingState is set to search A'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Stop loading search A (successful case)
|
||||||
|
service.stopLoading('search', 'A');
|
||||||
|
assert.strictEqual(
|
||||||
|
service.loadingState,
|
||||||
|
null,
|
||||||
|
'loadingState is cleared when stopped with matching parameters'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('it handles race condition: stopLoading only clears if parameters match', function (assert) {
|
||||||
|
let service = this.owner.lookup('service:map-ui');
|
||||||
|
|
||||||
|
// 1. Start loading search A
|
||||||
|
service.startLoading('search', 'A');
|
||||||
|
assert.deepEqual(service.loadingState, { type: 'search', value: 'A' });
|
||||||
|
|
||||||
|
// 2. Start loading search B (interruption)
|
||||||
|
// In a real app, search B would start before search A finishes.
|
||||||
|
service.startLoading('search', 'B');
|
||||||
|
assert.deepEqual(
|
||||||
|
service.loadingState,
|
||||||
|
{ type: 'search', value: 'B' },
|
||||||
|
'loadingState updates to search B'
|
||||||
|
);
|
||||||
|
|
||||||
|
// 3. Search A finishes and tries to stop loading
|
||||||
|
// The service should ignore this because current loading state is for B
|
||||||
|
service.stopLoading('search', 'A');
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
service.loadingState,
|
||||||
|
{ type: 'search', value: 'B' },
|
||||||
|
'loadingState remains search B even after stopping search A'
|
||||||
|
);
|
||||||
|
|
||||||
|
// 4. Search B finishes
|
||||||
|
service.stopLoading('search', 'B');
|
||||||
|
assert.strictEqual(
|
||||||
|
service.loadingState,
|
||||||
|
null,
|
||||||
|
'loadingState is cleared when search B stops'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -251,4 +251,45 @@ module('Unit | Service | osm', function (hooks) {
|
|||||||
[30, 30],
|
[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'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
39
tests/unit/utils/osm-icons-test.js
Normal file
39
tests/unit/utils/osm-icons-test.js
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import { getIconNameForTags } from 'marco/utils/osm-icons';
|
||||||
|
import { module, test } from 'qunit';
|
||||||
|
|
||||||
|
module('Unit | Utility | osm-icons', function () {
|
||||||
|
test('it returns molar-tooth for amenity=dentist', function (assert) {
|
||||||
|
let result = getIconNameForTags({ amenity: 'dentist' });
|
||||||
|
assert.strictEqual(result, 'molar-tooth');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('it returns molar-tooth for healthcare=dentist', function (assert) {
|
||||||
|
let result = getIconNameForTags({ healthcare: 'dentist' });
|
||||||
|
assert.strictEqual(result, 'molar-tooth');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('it returns greek-cross for healthcare=hospital (catch-all)', function (assert) {
|
||||||
|
let result = getIconNameForTags({ healthcare: 'hospital' });
|
||||||
|
assert.strictEqual(result, 'greek-cross');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('it returns greek-cross for healthcare=yes (catch-all)', function (assert) {
|
||||||
|
let result = getIconNameForTags({ healthcare: 'yes' });
|
||||||
|
assert.strictEqual(result, 'greek-cross');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('it returns shopping-basket for known shop types like convenience', function (assert) {
|
||||||
|
let result = getIconNameForTags({ shop: 'convenience' });
|
||||||
|
assert.strictEqual(result, 'shopping-basket');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('it returns shopping-bag for unknown shop types (catch-all)', function (assert) {
|
||||||
|
let result = getIconNameForTags({ shop: 'unknown_shop_type' });
|
||||||
|
assert.strictEqual(result, 'shopping-bag');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('it returns null for unknown tags', function (assert) {
|
||||||
|
let result = getIconNameForTags({ foo: 'bar' });
|
||||||
|
assert.strictEqual(result, null);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user