Compare commits
52 Commits
v1.14.0
...
5b37894821
| Author | SHA1 | Date | |
|---|---|---|---|
|
5b37894821
|
|||
|
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
|
|||
|
9af6636971
|
|||
|
8ae7fd1fd8
|
|||
|
cb3fbade39
|
|||
|
6dfd9765b4
|
|||
|
45eb8f087d
|
|||
|
3630fae133
|
|||
|
1116161e93
|
|||
|
88eb0ac0c1
|
|||
|
6da004e199
|
|||
|
8877a9e1c8
|
|||
|
03d6a86569
|
|||
|
5baebbb846
|
|||
|
dca2991754
|
|||
|
aee7f9d181
|
|||
|
56a077cceb
|
|||
|
7e5a034cac
|
|||
|
5892bd0cda
|
|||
|
baaf027900
|
|||
|
beb3d12169
|
|||
|
a5aa396411
|
|||
|
21cb8e6cc2
|
|||
|
12ec7fcbbf
|
|||
|
78d7aeba2c
|
14
.gitea/release-drafter.yml
Normal file
14
.gitea/release-drafter.yml
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
name-template: 'v$RESOLVED_VERSION'
|
||||||
|
tag-template: 'v$RESOLVED_VERSION'
|
||||||
|
version-resolver:
|
||||||
|
major:
|
||||||
|
labels:
|
||||||
|
- 'release/major'
|
||||||
|
minor:
|
||||||
|
labels:
|
||||||
|
- 'release/minor'
|
||||||
|
- 'feature'
|
||||||
|
patch:
|
||||||
|
labels:
|
||||||
|
- 'release/patch'
|
||||||
|
default: patch
|
||||||
13
.gitea/workflows/release_drafter.yml
Normal file
13
.gitea/workflows/release_drafter.yml
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
name: Release Drafter
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
types: [closed]
|
||||||
|
jobs:
|
||||||
|
release_drafter_job:
|
||||||
|
name: Update release notes draft
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Release Drafter
|
||||||
|
uses: https://github.com/raucao/gitea-release-drafter@dev
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
@@ -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,16 @@ 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;
|
||||||
@tracked isUserMenuOpen = false;
|
@tracked isUserMenuOpen = false;
|
||||||
|
@tracked searchQuery = '';
|
||||||
|
|
||||||
|
get hasQuery() {
|
||||||
|
return !!this.searchQuery;
|
||||||
|
}
|
||||||
|
|
||||||
@action
|
@action
|
||||||
toggleUserMenu() {
|
toggleUserMenu() {
|
||||||
@@ -21,10 +27,30 @@ 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 class="header-center {{if this.hasQuery 'searching'}}">
|
||||||
|
<CategoryChips @onSelect={{this.handleChipSelect}} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="header-right">
|
<div class="header-right">
|
||||||
|
|||||||
168
app/components/app-menu/about.gjs
Normal file
168
app/components/app-menu/about.gjs
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
import { on } from '@ember/modifier';
|
||||||
|
import Icon from '#components/icon';
|
||||||
|
|
||||||
|
<template>
|
||||||
|
{{! template-lint-disable no-nested-interactive }}
|
||||||
|
<div class="sidebar-header">
|
||||||
|
<button type="button" class="back-btn" {{on "click" @onBack}}>
|
||||||
|
<Icon @name="arrow-left" @size={{20}} @color="#333" />
|
||||||
|
</button>
|
||||||
|
<h2>About</h2>
|
||||||
|
<button type="button" class="close-btn" {{on "click" @onClose}}>
|
||||||
|
<Icon @name="x" @size={{20}} @color="#333" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sidebar-content">
|
||||||
|
<section class="about-section">
|
||||||
|
<p>
|
||||||
|
<strong>Marco</strong>
|
||||||
|
(as in
|
||||||
|
<a
|
||||||
|
href="https://en.wikipedia.org/wiki/Marco_Polo"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener"
|
||||||
|
>Marco Polo</a>) is an unhosted maps application that respects your
|
||||||
|
privacy and choices.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Connect your own
|
||||||
|
<a
|
||||||
|
href="https://remotestorage.io/"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener"
|
||||||
|
>remote storage</a>
|
||||||
|
to sync place bookmarks across apps and devices.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>
|
||||||
|
<Icon @name="gift" @size={{20}} />
|
||||||
|
<span>Open Source</span>
|
||||||
|
</summary>
|
||||||
|
<div class="details-content">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Source</th>
|
||||||
|
<th>License</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<a
|
||||||
|
href="https://gitea.kosmos.org/raucao/marco"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener"
|
||||||
|
>
|
||||||
|
Marco App
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<a
|
||||||
|
href="https://en.wikipedia.org/wiki/GNU_Affero_General_Public_License"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener"
|
||||||
|
>
|
||||||
|
<abbr title="GNU Affero General Public License">AGPL</abbr>
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<a
|
||||||
|
href="https://openstreetmap.org/copyright"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener"
|
||||||
|
>
|
||||||
|
Map Data
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<a
|
||||||
|
href="https://opendatacommons.org/licenses/odbl/"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener"
|
||||||
|
>
|
||||||
|
<abbr
|
||||||
|
title="Open Data Commons Open Database License"
|
||||||
|
>ODbL</abbr>
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<a
|
||||||
|
href="https://github.com/feathericons/feather"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener"
|
||||||
|
>
|
||||||
|
Feather Icons
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<a
|
||||||
|
href="https://en.wikipedia.org/wiki/MIT_License"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener"
|
||||||
|
>
|
||||||
|
<abbr title="MIT License">MIT</abbr>
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<a href="https://pinhead.ink/" target="_blank" rel="noopener">
|
||||||
|
Pinhead Icons
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<a
|
||||||
|
href="https://github.com/waysidemapping/pinhead?tab=readme-ov-file#where-the-icons-are-from"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener"
|
||||||
|
>
|
||||||
|
Various
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
<details>
|
||||||
|
<summary>
|
||||||
|
<Icon @name="heart" @size={{20}} @color="#e5533d" />
|
||||||
|
<span>Contribute</span>
|
||||||
|
</summary>
|
||||||
|
<div class="details-content">
|
||||||
|
<p>
|
||||||
|
<strong>Most impactful:</strong>
|
||||||
|
Add and improve data for points of interest in
|
||||||
|
<a
|
||||||
|
href="https://www.openstreetmap.org"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener"
|
||||||
|
>OpenStreetMap</a>.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong>Most appreciated:</strong>
|
||||||
|
Use this app as much as you can and
|
||||||
|
<a
|
||||||
|
href="https://community.remotestorage.io/t/marco-an-unhosted-maps-app/941"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener"
|
||||||
|
>submit feedback</a>
|
||||||
|
about your experience, problems, feature wishes, etc.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong>Most supportive:</strong>
|
||||||
|
Tell others about this app, on social media, in blog posts,
|
||||||
|
educational videos, etc.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
36
app/components/app-menu/home.gjs
Normal file
36
app/components/app-menu/home.gjs
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { on } from '@ember/modifier';
|
||||||
|
import { fn } from '@ember/helper';
|
||||||
|
import { htmlSafe } from '@ember/template';
|
||||||
|
import Icon from '#components/icon';
|
||||||
|
import iconRounded from '../../icons/icon-rounded.svg?raw';
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="sidebar-header">
|
||||||
|
<h2>
|
||||||
|
<span class="app-logo-icon">
|
||||||
|
{{htmlSafe iconRounded}}
|
||||||
|
</span>
|
||||||
|
Marco
|
||||||
|
</h2>
|
||||||
|
<button type="button" class="close-btn" {{on "click" @onClose}}>
|
||||||
|
<Icon @name="x" @size={{20}} @color="#333" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sidebar-content">
|
||||||
|
<ul class="app-menu">
|
||||||
|
<li>
|
||||||
|
<button type="button" {{on "click" (fn @onNavigate "settings")}}>
|
||||||
|
<Icon @name="settings" @size={{20}} />
|
||||||
|
<span>Settings</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<button type="button" {{on "click" (fn @onNavigate "about")}}>
|
||||||
|
<Icon @name="info" @size={{20}} />
|
||||||
|
<span>About</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
38
app/components/app-menu/index.gjs
Normal file
38
app/components/app-menu/index.gjs
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import Component from '@glimmer/component';
|
||||||
|
import { action } from '@ember/object';
|
||||||
|
import { tracked } from '@glimmer/tracking';
|
||||||
|
import { fn } from '@ember/helper';
|
||||||
|
import eq from 'ember-truth-helpers/helpers/eq';
|
||||||
|
|
||||||
|
import AppMenuHome from './home';
|
||||||
|
import AppMenuSettings from './settings';
|
||||||
|
import AppMenuAbout from './about';
|
||||||
|
|
||||||
|
export default class AppMenu extends Component {
|
||||||
|
@tracked currentView = 'menu'; // 'menu', 'settings', 'about'
|
||||||
|
|
||||||
|
@action
|
||||||
|
setView(view) {
|
||||||
|
this.currentView = view;
|
||||||
|
}
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="sidebar app-menu-pane">
|
||||||
|
{{#if (eq this.currentView "menu")}}
|
||||||
|
<AppMenuHome @onNavigate={{this.setView}} @onClose={{@onClose}} />
|
||||||
|
|
||||||
|
{{else if (eq this.currentView "settings")}}
|
||||||
|
<AppMenuSettings
|
||||||
|
@onBack={{fn this.setView "menu"}}
|
||||||
|
@onClose={{@onClose}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{{else if (eq this.currentView "about")}}
|
||||||
|
<AppMenuAbout
|
||||||
|
@onBack={{fn this.setView "menu"}}
|
||||||
|
@onClose={{@onClose}}
|
||||||
|
/>
|
||||||
|
{{/if}}
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
}
|
||||||
100
app/components/app-menu/settings.gjs
Normal file
100
app/components/app-menu/settings.gjs
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
import Component from '@glimmer/component';
|
||||||
|
import { on } from '@ember/modifier';
|
||||||
|
import { service } from '@ember/service';
|
||||||
|
import { action } from '@ember/object';
|
||||||
|
import Icon from '#components/icon';
|
||||||
|
import eq from 'ember-truth-helpers/helpers/eq';
|
||||||
|
|
||||||
|
export default class AppMenuSettings extends Component {
|
||||||
|
@service settings;
|
||||||
|
|
||||||
|
@action
|
||||||
|
updateApi(event) {
|
||||||
|
this.settings.updateOverpassApi(event.target.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
toggleKinetic(event) {
|
||||||
|
this.settings.updateMapKinetic(event.target.value === 'true');
|
||||||
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
updatePhotonApi(event) {
|
||||||
|
this.settings.updatePhotonApi(event.target.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="sidebar-header">
|
||||||
|
<button type="button" class="back-btn" {{on "click" @onBack}}>
|
||||||
|
<Icon @name="arrow-left" @size={{20}} @color="#333" />
|
||||||
|
</button>
|
||||||
|
<h2>Settings</h2>
|
||||||
|
<button type="button" class="close-btn" {{on "click" @onClose}}>
|
||||||
|
<Icon @name="x" @size={{20}} @color="#333" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sidebar-content">
|
||||||
|
<section class="settings-section">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="map-kinetic">Map Inertia (Kinetic Panning)</label>
|
||||||
|
<select
|
||||||
|
id="map-kinetic"
|
||||||
|
class="form-control"
|
||||||
|
{{on "change" this.toggleKinetic}}
|
||||||
|
>
|
||||||
|
<option
|
||||||
|
value="true"
|
||||||
|
selected={{if this.settings.mapKinetic "selected"}}
|
||||||
|
>
|
||||||
|
On
|
||||||
|
</option>
|
||||||
|
<option
|
||||||
|
value="false"
|
||||||
|
selected={{unless this.settings.mapKinetic "selected"}}
|
||||||
|
>
|
||||||
|
Off
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="overpass-api">Overpass API Provider</label>
|
||||||
|
<select
|
||||||
|
id="overpass-api"
|
||||||
|
class="form-control"
|
||||||
|
{{on "change" this.updateApi}}
|
||||||
|
>
|
||||||
|
{{#each this.settings.overpassApis as |api|}}
|
||||||
|
<option
|
||||||
|
value={{api.url}}
|
||||||
|
selected={{if
|
||||||
|
(eq api.url this.settings.overpassApi)
|
||||||
|
"selected"
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{{api.name}}
|
||||||
|
</option>
|
||||||
|
{{/each}}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="photon-api">Photon API Provider</label>
|
||||||
|
<select
|
||||||
|
id="photon-api"
|
||||||
|
class="form-control"
|
||||||
|
{{on "change" this.updatePhotonApi}}
|
||||||
|
>
|
||||||
|
{{#each this.settings.photonApis as |api|}}
|
||||||
|
<option
|
||||||
|
value={{api.url}}
|
||||||
|
selected={{if (eq api.url this.settings.photonApi) "selected"}}
|
||||||
|
>
|
||||||
|
{{api.name}}
|
||||||
|
</option>
|
||||||
|
{{/each}}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
}
|
||||||
52
app/components/category-chips.gjs
Normal file
52
app/components/category-chips.gjs
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import Component from '@glimmer/component';
|
||||||
|
import { service } from '@ember/service';
|
||||||
|
import { action } from '@ember/object';
|
||||||
|
import { on } from '@ember/modifier';
|
||||||
|
import { fn } from '@ember/helper';
|
||||||
|
import Icon from '#components/icon';
|
||||||
|
import { POI_CATEGORIES } from '../utils/poi-categories';
|
||||||
|
|
||||||
|
export default class CategoryChipsComponent extends Component {
|
||||||
|
@service router;
|
||||||
|
@service mapUi;
|
||||||
|
|
||||||
|
get categories() {
|
||||||
|
return POI_CATEGORIES;
|
||||||
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
searchCategory(category) {
|
||||||
|
// If passed an onSelect action, call it (e.g. to clear search box)
|
||||||
|
if (this.args.onSelect) {
|
||||||
|
this.args.onSelect(category);
|
||||||
|
}
|
||||||
|
|
||||||
|
let queryParams = { category: category.id, q: null };
|
||||||
|
|
||||||
|
if (this.mapUi.currentCenter) {
|
||||||
|
const { lat, lon } = this.mapUi.currentCenter;
|
||||||
|
queryParams.lat = parseFloat(lat).toFixed(4);
|
||||||
|
queryParams.lon = parseFloat(lon).toFixed(4);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.router.transitionTo('search', { queryParams });
|
||||||
|
}
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="category-chips-scroll">
|
||||||
|
<div class="category-chips-container">
|
||||||
|
{{#each this.categories as |category|}}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="category-chip"
|
||||||
|
{{on "click" (fn this.searchCategory category)}}
|
||||||
|
aria-label={{category.label}}
|
||||||
|
>
|
||||||
|
<Icon @name={{category.icon}} @size={{16}} />
|
||||||
|
<span>{{category.label}}</span>
|
||||||
|
</button>
|
||||||
|
{{/each}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import Component from '@glimmer/component';
|
import 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}}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ 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';
|
||||||
|
|
||||||
export default class MapComponent extends Component {
|
export default class MapComponent extends Component {
|
||||||
@@ -28,6 +28,7 @@ export default class MapComponent extends Component {
|
|||||||
|
|
||||||
mapInstance;
|
mapInstance;
|
||||||
bookmarkSource;
|
bookmarkSource;
|
||||||
|
searchResultsSource;
|
||||||
selectedShapeSource;
|
selectedShapeSource;
|
||||||
searchOverlay;
|
searchOverlay;
|
||||||
searchOverlayElement;
|
searchOverlayElement;
|
||||||
@@ -110,6 +111,47 @@ 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();
|
||||||
|
let cachedSearchResultSvgUrl = null;
|
||||||
|
|
||||||
|
const searchResultStyle = (feature) => {
|
||||||
|
if (!cachedSearchResultSvgUrl) {
|
||||||
|
const markerColor =
|
||||||
|
getComputedStyle(document.documentElement)
|
||||||
|
.getPropertyValue('--marker-color-primary')
|
||||||
|
.trim() || '#ea4335';
|
||||||
|
|
||||||
|
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)"/>
|
||||||
|
<circle cx="12" cy="12" r="8" fill="${markerColor}"/>
|
||||||
|
</svg>
|
||||||
|
`;
|
||||||
|
cachedSearchResultSvgUrl =
|
||||||
|
'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(svg.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Style({
|
||||||
|
image: new Icon({
|
||||||
|
src: cachedSearchResultSvgUrl,
|
||||||
|
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;
|
||||||
@@ -143,7 +185,7 @@ export default class MapComponent extends Component {
|
|||||||
|
|
||||||
this.mapInstance = new Map({
|
this.mapInstance = new Map({
|
||||||
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 +220,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 +506,36 @@ 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) => {
|
||||||
|
// Don't render if it's already a bookmark to avoid clutter/overlap
|
||||||
|
// Although user might want to see it's a search result...
|
||||||
|
// Let's render it, but z-index handles overlap (bookmarks are higher)
|
||||||
|
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;
|
||||||
@@ -499,10 +571,9 @@ export default class MapComponent extends Component {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (options.preventZoom) {
|
if (options.preventZoom) {
|
||||||
// If we are preventing zoom (e.g. user clicked a bookmark), we still need to center
|
// If we are preventing zoom (e.g. user clicked a bookmark), we rely on visibility check.
|
||||||
// but without changing the zoom level.
|
// This avoids unnecessary panning if the place is already visible.
|
||||||
// We use animateToSmartCenter without a second argument (zoom=null).
|
this.handlePinVisibility(coords, { maintainZoom: true });
|
||||||
this.animateToSmartCenter(coords);
|
|
||||||
} else if (selected.bbox) {
|
} else if (selected.bbox) {
|
||||||
this.zoomToBbox(selected.bbox);
|
this.zoomToBbox(selected.bbox);
|
||||||
} else {
|
} else {
|
||||||
@@ -547,7 +618,10 @@ export default class MapComponent extends Component {
|
|||||||
}
|
}
|
||||||
// Desktop: Sidebar covers left side (approx 400px)
|
// Desktop: Sidebar covers left side (approx 400px)
|
||||||
else if (this.args.isSidebarOpen) {
|
else if (this.args.isSidebarOpen) {
|
||||||
const sidebarWidth = 400;
|
const sidebarWidthVar = getComputedStyle(document.documentElement)
|
||||||
|
.getPropertyValue('--sidebar-width')
|
||||||
|
.trim();
|
||||||
|
const sidebarWidth = parseInt(sidebarWidthVar, 10) || 360;
|
||||||
const visibleWidth = size[0] - sidebarWidth;
|
const visibleWidth = size[0] - sidebarWidth;
|
||||||
|
|
||||||
// Left padding: Sidebar + 15% of visible width
|
// Left padding: Sidebar + 15% of visible width
|
||||||
@@ -566,14 +640,15 @@ export default class MapComponent extends Component {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
handlePinVisibility(coords) {
|
handlePinVisibility(coords, options = {}) {
|
||||||
if (!this.mapInstance) return;
|
if (!this.mapInstance) return;
|
||||||
|
|
||||||
const view = this.mapInstance.getView();
|
const view = this.mapInstance.getView();
|
||||||
const currentZoom = view.getZoom();
|
const currentZoom = view.getZoom();
|
||||||
|
|
||||||
// If too far out (e.g. world view), zoom in to neighborhood level (16)
|
// If too far out (e.g. world view), zoom in to neighborhood level (16)
|
||||||
if (currentZoom < 16) {
|
// UNLESS we want to maintain the current zoom
|
||||||
|
if (!options.maintainZoom && currentZoom < 16) {
|
||||||
this.animateToSmartCenter(coords, 16);
|
this.animateToSmartCenter(coords, 16);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -590,8 +665,12 @@ export default class MapComponent extends Component {
|
|||||||
pixel[1] > size[1];
|
pixel[1] > size[1];
|
||||||
|
|
||||||
if (isOffScreen) {
|
if (isOffScreen) {
|
||||||
this.animateToSmartCenter(coords);
|
// If off-screen, center it smartly (considering sidebar/bottom sheet)
|
||||||
|
// Pass maintainZoom to prevent zoom reset if desired
|
||||||
|
const zoom = options.maintainZoom ? null : 16;
|
||||||
|
this.animateToSmartCenter(coords, zoom);
|
||||||
} else {
|
} else {
|
||||||
|
// If on-screen, only pan if obscured by UI
|
||||||
this.panIfObscured(coords);
|
this.panIfObscured(coords);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -627,6 +706,28 @@ export default class MapComponent extends Component {
|
|||||||
// To move the camera South (Lower Y), we subtract.
|
// To move the camera South (Lower Y), we subtract.
|
||||||
targetCenter = [coords[0], coords[1] - offsetMapUnits];
|
targetCenter = [coords[0], coords[1] - offsetMapUnits];
|
||||||
}
|
}
|
||||||
|
// Desktop: Check if sidebar is open
|
||||||
|
else if (this.args.isSidebarOpen) {
|
||||||
|
const sidebarWidthVar = getComputedStyle(document.documentElement)
|
||||||
|
.getPropertyValue('--sidebar-width')
|
||||||
|
.trim();
|
||||||
|
const sidebarWidth = parseInt(sidebarWidthVar, 10) || 360;
|
||||||
|
|
||||||
|
// We want the pin to be in the center of the remaining space.
|
||||||
|
// Remaining space starts at x = sidebarWidth.
|
||||||
|
// Center of remaining space = sidebarWidth + (totalWidth - sidebarWidth) / 2
|
||||||
|
// = sidebarWidth/2 + totalWidth/2
|
||||||
|
// Map Center is totalWidth/2
|
||||||
|
// Offset = sidebarWidth/2 (to the right)
|
||||||
|
|
||||||
|
const offsetPixels = sidebarWidth / 2;
|
||||||
|
const offsetMapUnits = offsetPixels * resolution;
|
||||||
|
|
||||||
|
// We want pin at center + offset.
|
||||||
|
// So map center must be pin - offset.
|
||||||
|
// X increases to the right.
|
||||||
|
targetCenter = [coords[0] - offsetMapUnits, coords[1]];
|
||||||
|
}
|
||||||
|
|
||||||
const animationOptions = {
|
const animationOptions = {
|
||||||
center: targetCenter,
|
center: targetCenter,
|
||||||
@@ -645,33 +746,73 @@ export default class MapComponent extends Component {
|
|||||||
if (!this.mapInstance) return;
|
if (!this.mapInstance) return;
|
||||||
|
|
||||||
const size = this.mapInstance.getSize();
|
const size = this.mapInstance.getSize();
|
||||||
// Check if mobile (width <= 768px matches CSS)
|
|
||||||
if (size[0] > 768) return;
|
|
||||||
|
|
||||||
const pixel = this.mapInstance.getPixelFromCoordinate(coords);
|
const pixel = this.mapInstance.getPixelFromCoordinate(coords);
|
||||||
if (!pixel) return;
|
if (!pixel) return;
|
||||||
|
|
||||||
const height = size[1];
|
|
||||||
|
|
||||||
// Sidebar covers the bottom 50%
|
|
||||||
const splitPoint = height / 2;
|
|
||||||
|
|
||||||
// If the pin is in the bottom half (y > splitPoint), it is obscured
|
|
||||||
if (pixel[1] > splitPoint) {
|
|
||||||
// Target position: Center of top half = height * 0.25
|
|
||||||
const targetY = height * 0.25;
|
|
||||||
const deltaY = pixel[1] - targetY;
|
|
||||||
|
|
||||||
const view = this.mapInstance.getView();
|
const view = this.mapInstance.getView();
|
||||||
const center = view.getCenter();
|
const center = view.getCenter();
|
||||||
const resolution = view.getResolution();
|
const resolution = view.getResolution();
|
||||||
|
|
||||||
// Move the map center SOUTH (decrease Y) to move the pin UP (decrease pixel Y)
|
// Default targets (current position)
|
||||||
const deltaMapUnits = deltaY * resolution;
|
let targetPixelX = pixel[0];
|
||||||
const newCenter = [center[0], center[1] - deltaMapUnits];
|
let targetPixelY = pixel[1];
|
||||||
|
let needsPan = false;
|
||||||
|
|
||||||
|
// 1. Mobile Bottom Sheet Logic (Screen <= 768px)
|
||||||
|
if (size[0] <= 768) {
|
||||||
|
const height = size[1];
|
||||||
|
const splitPoint = height / 2;
|
||||||
|
|
||||||
|
// If in bottom half
|
||||||
|
if (pixel[1] > splitPoint) {
|
||||||
|
targetPixelY = height * 0.25; // Target: Center of top half
|
||||||
|
needsPan = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 2. Desktop Sidebar Logic (Screen > 768px + Sidebar Open)
|
||||||
|
else if (this.args.isSidebarOpen) {
|
||||||
|
const sidebarWidthVar = getComputedStyle(document.documentElement)
|
||||||
|
.getPropertyValue('--sidebar-width')
|
||||||
|
.trim();
|
||||||
|
const sidebarWidth = parseInt(sidebarWidthVar, 10) || 360;
|
||||||
|
|
||||||
|
// If under sidebar
|
||||||
|
if (pixel[0] < sidebarWidth) {
|
||||||
|
const visibleWidth = size[0] - sidebarWidth;
|
||||||
|
targetPixelX = sidebarWidth + visibleWidth / 2; // Target: Center of visible area
|
||||||
|
needsPan = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Header Logic (Any screen size)
|
||||||
|
// Check if the (potentially new) target Y is under the header
|
||||||
|
const headerHeight = 60;
|
||||||
|
const minTopDistance = headerHeight + 20; // 80px
|
||||||
|
|
||||||
|
if (targetPixelY < minTopDistance) {
|
||||||
|
targetPixelY = minTopDistance + 30; // Move it to ~110px, clear of header
|
||||||
|
needsPan = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (needsPan) {
|
||||||
|
const deltaPixelX = pixel[0] - targetPixelX;
|
||||||
|
const deltaPixelY = pixel[1] - targetPixelY;
|
||||||
|
|
||||||
|
// X: Camera moves same direction as we want the world to move? No.
|
||||||
|
// If we want pin to move RIGHT (pixel increases), Camera must move LEFT (X decreases).
|
||||||
|
// deltaPixelX = current - target. If current < target (want move right), delta is negative.
|
||||||
|
// center + negative = decrease. Correct.
|
||||||
|
const newCenterX = center[0] + deltaPixelX * resolution;
|
||||||
|
|
||||||
|
// Y: Camera moves opposite direction to world relative to pixel coords.
|
||||||
|
// Pixel Y increases DOWN. Map Y increases UP.
|
||||||
|
// If we want pin to move DOWN (pixel increases), Camera must move UP (Y increases).
|
||||||
|
// deltaPixelY = current - target. If current < target (want move down), delta is negative.
|
||||||
|
// center - negative = increase. Correct.
|
||||||
|
const newCenterY = center[1] - deltaPixelY * resolution;
|
||||||
|
|
||||||
view.animate({
|
view.animate({
|
||||||
center: newCenter,
|
center: [newCenterX, newCenterY],
|
||||||
duration: 500,
|
duration: 500,
|
||||||
easing: (t) => t * (2 - t), // Ease-out
|
easing: (t) => t * (2 - t), // Ease-out
|
||||||
});
|
});
|
||||||
@@ -845,6 +986,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);
|
||||||
|
|
||||||
@@ -876,6 +1018,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) {
|
||||||
@@ -884,8 +1027,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();
|
||||||
@@ -896,14 +1043,15 @@ export default class MapComponent extends Component {
|
|||||||
|
|
||||||
// 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;
|
this.mapUi.preventNextZoom = true;
|
||||||
this.router.transitionTo('place', clickedBookmark);
|
this.router.transitionTo('place', targetPlace);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -922,6 +1070,13 @@ export default class MapComponent extends Component {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (clickedSearchResult) {
|
||||||
|
console.debug('Clicked search result:', clickedSearchResult);
|
||||||
|
this.mapUi.preventNextZoom = true;
|
||||||
|
this.router.transitionTo('place', clickedSearchResult);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Require Zoom >= 17 for generic map searches
|
// Require Zoom >= 17 for generic map searches
|
||||||
// This prevents accidental searches when interacting with the map at a high level
|
// This prevents accidental searches when interacting with the map at a high level
|
||||||
const currentZoom = this.mapInstance.getView().getZoom();
|
const currentZoom = this.mapInstance.getView().getZoom();
|
||||||
@@ -971,6 +1126,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}}
|
||||||
|
|||||||
@@ -286,7 +286,7 @@ export default class PlaceDetails extends Component {
|
|||||||
>
|
>
|
||||||
<Icon
|
<Icon
|
||||||
@name="bookmark"
|
@name="bookmark"
|
||||||
@color={{if this.isSaved "currentColor" "#007bff"}}
|
@color={{if this.isSaved "currentColor" "var(--link-color)"}}
|
||||||
/>
|
/>
|
||||||
{{if this.isSaved "Saved" "Save"}}
|
{{if this.isSaved "Saved" "Save"}}
|
||||||
</button>
|
</button>
|
||||||
@@ -305,9 +305,10 @@ 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="#007bff" />
|
<Icon @name="edit" @color="var(--link-color)" />
|
||||||
Edit
|
Edit
|
||||||
</button>
|
</button>
|
||||||
{{/if}}
|
{{/if}}
|
||||||
@@ -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>
|
||||||
@@ -226,7 +226,7 @@ export default class PlacesSidebar extends Component {
|
|||||||
class="btn btn-outline create-place"
|
class="btn btn-outline create-place"
|
||||||
{{on "click" this.createNewPlace}}
|
{{on "click" this.createNewPlace}}
|
||||||
>
|
>
|
||||||
<Icon @name="plus" @size={{18}} @color="#007bff" />
|
<Icon @name="plus" @size={{18}} @color="var(--link-color)" />
|
||||||
Create new place
|
Create new place
|
||||||
</button>
|
</button>
|
||||||
{{/if}}
|
{{/if}}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ 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 { POI_CATEGORIES } from '../utils/poi-categories';
|
||||||
import eq from 'ember-truth-helpers/helpers/eq';
|
import eq from 'ember-truth-helpers/helpers/eq';
|
||||||
|
|
||||||
export default class SearchBoxComponent extends Component {
|
export default class SearchBoxComponent extends Component {
|
||||||
@@ -15,30 +16,45 @@ export default class SearchBoxComponent extends Component {
|
|||||||
@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 +63,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 +108,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 +122,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 +170,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,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -121,8 +180,10 @@ export default class SearchBoxComponent extends Component {
|
|||||||
clear() {
|
clear() {
|
||||||
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>
|
||||||
@@ -176,7 +237,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>
|
||||||
|
|||||||
@@ -1,128 +0,0 @@
|
|||||||
import Component from '@glimmer/component';
|
|
||||||
import { on } from '@ember/modifier';
|
|
||||||
import { service } from '@ember/service';
|
|
||||||
import { action } from '@ember/object';
|
|
||||||
import Icon from '#components/icon';
|
|
||||||
import eq from 'ember-truth-helpers/helpers/eq';
|
|
||||||
|
|
||||||
export default class SettingsPane extends Component {
|
|
||||||
@service settings;
|
|
||||||
|
|
||||||
@action
|
|
||||||
updateApi(event) {
|
|
||||||
this.settings.updateOverpassApi(event.target.value);
|
|
||||||
}
|
|
||||||
|
|
||||||
@action
|
|
||||||
toggleKinetic(event) {
|
|
||||||
this.settings.updateMapKinetic(event.target.value === 'true');
|
|
||||||
}
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="sidebar settings-pane">
|
|
||||||
<div class="sidebar-header">
|
|
||||||
<h2>
|
|
||||||
<img src="/icons/icon-rounded.svg" alt="" width="32" height="32" />
|
|
||||||
Marco
|
|
||||||
</h2>
|
|
||||||
<button type="button" class="close-btn" {{on "click" @onClose}}>
|
|
||||||
<Icon @name="x" @size={{20}} @color="#333" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="sidebar-content">
|
|
||||||
<section class="settings-section">
|
|
||||||
<h3>Settings</h3>
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="map-kinetic">Map Inertia (Kinetic Panning)</label>
|
|
||||||
<select
|
|
||||||
id="map-kinetic"
|
|
||||||
class="form-control"
|
|
||||||
{{on "change" this.toggleKinetic}}
|
|
||||||
>
|
|
||||||
<option
|
|
||||||
value="true"
|
|
||||||
selected={{if this.settings.mapKinetic "selected"}}
|
|
||||||
>
|
|
||||||
On
|
|
||||||
</option>
|
|
||||||
<option
|
|
||||||
value="false"
|
|
||||||
selected={{unless this.settings.mapKinetic "selected"}}
|
|
||||||
>
|
|
||||||
Off
|
|
||||||
</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="overpass-api">Overpass API Provider</label>
|
|
||||||
<select
|
|
||||||
id="overpass-api"
|
|
||||||
class="form-control"
|
|
||||||
{{on "change" this.updateApi}}
|
|
||||||
>
|
|
||||||
{{#each this.settings.overpassApis as |api|}}
|
|
||||||
<option
|
|
||||||
value={{api.url}}
|
|
||||||
selected={{if
|
|
||||||
(eq api.url this.settings.overpassApi)
|
|
||||||
"selected"
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{{api.name}}
|
|
||||||
</option>
|
|
||||||
{{/each}}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
<section class="settings-section">
|
|
||||||
<h3>About</h3>
|
|
||||||
<p>
|
|
||||||
<strong>Marco</strong>
|
|
||||||
(as in
|
|
||||||
<a
|
|
||||||
href="https://en.wikipedia.org/wiki/Marco_Polo"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener"
|
|
||||||
>Marco Polo</a>) is an unhosted maps application that respects your
|
|
||||||
privacy and choices.
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
Connect your own
|
|
||||||
<a
|
|
||||||
href="https://remotestorage.io/"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener"
|
|
||||||
>remote storage</a>
|
|
||||||
to sync place bookmarks across apps and devices.
|
|
||||||
</p>
|
|
||||||
<ul class="link-list">
|
|
||||||
<li>
|
|
||||||
<a
|
|
||||||
href="https://gitea.kosmos.org/raucao/marco"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener"
|
|
||||||
>
|
|
||||||
Source Code
|
|
||||||
</a>
|
|
||||||
(<a
|
|
||||||
href="https://en.wikipedia.org/wiki/GNU_Affero_General_Public_License"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener"
|
|
||||||
>AGPL</a>)
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<a
|
|
||||||
href="https://openstreetmap.org/copyright"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener"
|
|
||||||
>
|
|
||||||
Map Data © OpenStreetMap
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</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;
|
||||||
}
|
}
|
||||||
|
|||||||
45
app/icons/icon-rounded.svg
Normal file
45
app/icons/icon-rounded.svg
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
<svg
|
||||||
|
width="1024"
|
||||||
|
height="1024"
|
||||||
|
viewBox="0 0 1024 1024"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<!-- Background -->
|
||||||
|
<rect
|
||||||
|
x="0"
|
||||||
|
y="0"
|
||||||
|
width="1024"
|
||||||
|
height="1024"
|
||||||
|
rx="220"
|
||||||
|
fill="#F6E9A6"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Subtle map grid (kept well outside safe zone) -->
|
||||||
|
<g stroke="#E6D88A" stroke-width="10" opacity="0.6">
|
||||||
|
<line x1="256" y1="0" x2="256" y2="1024" />
|
||||||
|
<line x1="512" y1="0" x2="512" y2="1024" />
|
||||||
|
<line x1="768" y1="0" x2="768" y2="1024" />
|
||||||
|
|
||||||
|
<line x1="0" y1="256" x2="1024" y2="256" />
|
||||||
|
<line x1="0" y1="512" x2="1024" y2="512" />
|
||||||
|
<line x1="0" y1="768" x2="1024" y2="768" />
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Location pin (exact app shape, larger, centered, safe-zone compliant) -->
|
||||||
|
<!-- Safe zone target: ~680px diameter -->
|
||||||
|
<g
|
||||||
|
transform="
|
||||||
|
translate(512 512)
|
||||||
|
scale(22)
|
||||||
|
translate(-12 -12)
|
||||||
|
"
|
||||||
|
fill="#ea4335"
|
||||||
|
stroke="#b31412"
|
||||||
|
stroke-width="1"
|
||||||
|
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" />
|
||||||
|
<circle cx="12" cy="10" r="3" fill="#b31412" stroke="none" />
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.1 KiB |
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) {
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ export default class SearchRoute extends Route {
|
|||||||
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) {
|
||||||
@@ -22,8 +23,37 @@ export default class SearchRoute extends Route {
|
|||||||
const lon = params.lon ? parseFloat(params.lon) : null;
|
const lon = params.lon ? parseFloat(params.lon) : null;
|
||||||
let pois = [];
|
let pois = [];
|
||||||
|
|
||||||
|
// Case 0: Category Search (category parameter present)
|
||||||
|
if (params.category && lat && lon) {
|
||||||
|
// We need bounds. If we have active map state, use it.
|
||||||
|
let bounds = this.mapUi.currentBounds;
|
||||||
|
|
||||||
|
// If we don't have bounds (direct URL visit), estimate them from lat/lon/zoom(16)
|
||||||
|
// or just use a fixed box around the center.
|
||||||
|
if (!bounds) {
|
||||||
|
// Approximate 0.01 degrees ~ 1km at equator. A viewport is roughly 0.02x0.01 at zoom 16?
|
||||||
|
// Let's take a safe box of ~1km radius.
|
||||||
|
const delta = 0.01;
|
||||||
|
bounds = {
|
||||||
|
minLat: lat - delta,
|
||||||
|
maxLat: lat + delta,
|
||||||
|
minLon: lon - delta,
|
||||||
|
maxLon: lon + delta,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
pois = await this.osm.getCategoryPois(bounds, params.category, lat, lon);
|
||||||
|
|
||||||
|
// Sort by distance from center
|
||||||
|
pois = pois
|
||||||
|
.map((p) => ({
|
||||||
|
...p,
|
||||||
|
_distance: getDistance(lat, lon, p.lat, p.lon),
|
||||||
|
}))
|
||||||
|
.sort((a, b) => a._distance - b._distance);
|
||||||
|
}
|
||||||
// Case 1: Text Search (q parameter present)
|
// Case 1: Text Search (q parameter present)
|
||||||
if (params.q) {
|
else if (params.q) {
|
||||||
// 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);
|
||||||
|
|
||||||
@@ -139,6 +169,7 @@ 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);
|
||||||
}
|
}
|
||||||
|
|
||||||
@action
|
@action
|
||||||
|
|||||||
@@ -8,9 +8,11 @@ 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 = [];
|
||||||
|
|
||||||
selectPlace(place, options = {}) {
|
selectPlace(place, options = {}) {
|
||||||
this.selectedPlace = place;
|
this.selectedPlace = place;
|
||||||
@@ -23,6 +25,14 @@ export default class MapUiService extends Service {
|
|||||||
this.preventNextZoom = false;
|
this.preventNextZoom = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setSearchResults(results) {
|
||||||
|
this.searchResults = results || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
clearSearchResults() {
|
||||||
|
this.searchResults = [];
|
||||||
|
}
|
||||||
|
|
||||||
startSearch() {
|
startSearch() {
|
||||||
this.isSearching = true;
|
this.isSearching = true;
|
||||||
this.isCreating = false;
|
this.isCreating = false;
|
||||||
@@ -54,4 +64,8 @@ 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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -76,6 +77,70 @@ out center;
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getCategoryPois(bounds, categoryId, lat, lon) {
|
||||||
|
const category = getCategoryById(categoryId);
|
||||||
|
if (!category || !bounds) return [];
|
||||||
|
|
||||||
|
const queryKey = lat && lon ? `cat:${categoryId}:${lat}:${lon}` : null;
|
||||||
|
|
||||||
|
if (queryKey && this.lastQueryKey === queryKey && this.cachedResults) {
|
||||||
|
console.debug('Returning cached category results for:', queryKey);
|
||||||
|
return this.cachedResults;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.controller) {
|
||||||
|
this.controller.abort();
|
||||||
|
}
|
||||||
|
this.controller = new AbortController();
|
||||||
|
const signal = this.controller.signal;
|
||||||
|
|
||||||
|
const { minLat, minLon, maxLat, maxLon } = bounds;
|
||||||
|
|
||||||
|
// Build the query parts for each filter string and type
|
||||||
|
const queryParts = [];
|
||||||
|
|
||||||
|
// Default types if not specified (legacy fallback)
|
||||||
|
const types = category.types || ['node', 'way', 'relation'];
|
||||||
|
|
||||||
|
category.filter.forEach((filterString) => {
|
||||||
|
types.forEach((type) => {
|
||||||
|
// We ensure we only fetch named POIs to reduce noise
|
||||||
|
queryParts.push(`${type}${filterString}[~"^name"~"."];`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const query = `
|
||||||
|
[out:json][timeout:25][bbox:${minLat},${minLon},${maxLat},${maxLon}];
|
||||||
|
(
|
||||||
|
${queryParts.join('\n ')}
|
||||||
|
);
|
||||||
|
out center;
|
||||||
|
`.trim();
|
||||||
|
|
||||||
|
const url = `${this.settings.overpassApi}?data=${encodeURIComponent(query)}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await this.fetchWithRetry(url, { signal });
|
||||||
|
if (!res.ok) throw new Error('Overpass request failed');
|
||||||
|
const data = await res.json();
|
||||||
|
const results = data.elements.map(this.normalizePoi);
|
||||||
|
|
||||||
|
if (queryKey) {
|
||||||
|
this.lastQueryKey = queryKey;
|
||||||
|
this.cachedResults = results;
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
} catch (e) {
|
||||||
|
if (e.name === 'AbortError') {
|
||||||
|
console.debug('Category search aborted');
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
console.error('Category search failed', e);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
normalizePoi(poi) {
|
normalizePoi(poi) {
|
||||||
const tags = poi.tags || {};
|
const tags = poi.tags || {};
|
||||||
const type = getPlaceType(tags) || 'Point of Interest';
|
const type = getPlaceType(tags) || 'Point of Interest';
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
import Service from '@ember/service';
|
import Service, { service } from '@ember/service';
|
||||||
import { getPlaceType } from '../utils/osm';
|
import { getPlaceType } from '../utils/osm';
|
||||||
import { humanizeOsmTag } from '../utils/format-text';
|
import { humanizeOsmTag } from '../utils/format-text';
|
||||||
|
|
||||||
export default class PhotonService extends Service {
|
export default class PhotonService extends Service {
|
||||||
baseUrl = 'https://photon.komoot.io/api/';
|
@service settings;
|
||||||
|
|
||||||
|
get baseUrl() {
|
||||||
|
return this.settings.photonApi;
|
||||||
|
}
|
||||||
|
|
||||||
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 [];
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { tracked } from '@glimmer/tracking';
|
|||||||
export default class SettingsService extends Service {
|
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/';
|
||||||
|
|
||||||
overpassApis = [
|
overpassApis = [
|
||||||
{
|
{
|
||||||
@@ -24,6 +25,13 @@ export default class SettingsService extends Service {
|
|||||||
// },
|
// },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
photonApis = [
|
||||||
|
{
|
||||||
|
name: 'photon.komoot.io',
|
||||||
|
url: 'https://photon.komoot.io/api/',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super(...arguments);
|
super(...arguments);
|
||||||
this.loadSettings();
|
this.loadSettings();
|
||||||
@@ -59,4 +67,8 @@ export default class SettingsService extends Service {
|
|||||||
this.mapKinetic = enabled;
|
this.mapKinetic = enabled;
|
||||||
localStorage.setItem('marco:map-kinetic', String(enabled));
|
localStorage.setItem('marco:map-kinetic', String(enabled));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
updatePhotonApi(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;
|
||||||
|
|||||||
@@ -2,6 +2,12 @@
|
|||||||
|
|
||||||
:root {
|
:root {
|
||||||
--default-list-color: #fc3;
|
--default-list-color: #fc3;
|
||||||
|
--hover-bg: #f8f9fa;
|
||||||
|
--sidebar-width: 350px;
|
||||||
|
--link-color: #2a7fff;
|
||||||
|
--link-color-visited: #6a4fbf;
|
||||||
|
--marker-color-primary: #ea4335;
|
||||||
|
--marker-color-dark: #b31412;
|
||||||
}
|
}
|
||||||
|
|
||||||
html,
|
html,
|
||||||
@@ -66,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 {
|
||||||
@@ -184,7 +259,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.text-primary {
|
.text-primary {
|
||||||
color: #007bff;
|
color: var(--link-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
.text-danger {
|
.text-danger {
|
||||||
@@ -201,7 +276,7 @@ body {
|
|||||||
top: 0;
|
top: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
width: 300px;
|
width: var(--sidebar-width);
|
||||||
background: white;
|
background: white;
|
||||||
z-index: 3100; /* Higher than Header (3000) */
|
z-index: 3100; /* Higher than Header (3000) */
|
||||||
box-shadow: 2px 0 5px rgb(0 0 0 / 10%);
|
box-shadow: 2px 0 5px rgb(0 0 0 / 10%);
|
||||||
@@ -210,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;
|
||||||
@@ -251,10 +326,111 @@ body {
|
|||||||
overscroll-behavior: contain;
|
overscroll-behavior: contain;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.app-menu-pane .sidebar-content {
|
||||||
|
padding-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-menu {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0 -1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-menu button {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.8rem;
|
||||||
|
padding: 1rem;
|
||||||
|
padding-left: 1.4rem;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: #333;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-family: inherit;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-menu button:hover {
|
||||||
|
background-color: var(--hover-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-menu .icon {
|
||||||
|
color: #666;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-content details {
|
||||||
|
margin: 0 -1rem; /* Top margin, negative side margins to span full width */
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-content details summary {
|
||||||
|
list-style: none; /* Hide default triangle */
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.8rem;
|
||||||
|
padding: 1rem;
|
||||||
|
padding-left: 1.4rem;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
color: #333;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-content details summary::-webkit-details-marker {
|
||||||
|
display: none; /* Hide default triangle in WebKit */
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-content details summary:hover {
|
||||||
|
background-color: var(--hover-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-content details summary .icon {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-content details summary::after {
|
||||||
|
content: '';
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='%23666' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='9 18 15 12 9 6'/%3E%3C/svg%3E");
|
||||||
|
background-size: 20px 20px;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-position: center;
|
||||||
|
margin-left: auto;
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-content details[open] summary::after {
|
||||||
|
transform: rotate(90deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-content details .details-content {
|
||||||
|
padding: 0 1.4rem 1rem;
|
||||||
|
animation: details-slide-down 0.2s ease-out;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes details-slide-down {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-5px);
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.edit-form {
|
.edit-form {
|
||||||
margin: -1rem;
|
margin: -1rem;
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
background: #f8f9fa;
|
background: var(--hover-bg);
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
border-bottom: 1px solid #eee;
|
border-bottom: 1px solid #eee;
|
||||||
}
|
}
|
||||||
@@ -278,12 +454,25 @@ body {
|
|||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
box-sizing: border-box; /* Ensure padding doesn't overflow width */
|
box-sizing: border-box; /* Ensure padding doesn't overflow width */
|
||||||
|
color: #333;
|
||||||
|
background-color: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-control:focus {
|
.form-control:focus {
|
||||||
outline: none;
|
outline: none;
|
||||||
border-color: #007bff;
|
border-color: var(--link-color);
|
||||||
box-shadow: 0 0 0 2px rgb(0 123 255 / 10%);
|
box-shadow: 0 0 0 2px rgb(42 127 255 / 10%);
|
||||||
|
}
|
||||||
|
|
||||||
|
select.form-control {
|
||||||
|
appearance: none;
|
||||||
|
background-color: #fff;
|
||||||
|
background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%23666' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E");
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-position: right 0.75rem center;
|
||||||
|
background-size: 16px 16px;
|
||||||
|
padding-right: 2.5rem;
|
||||||
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.edit-actions {
|
.edit-actions {
|
||||||
@@ -294,27 +483,27 @@ body {
|
|||||||
|
|
||||||
.settings-section {
|
.settings-section {
|
||||||
margin-bottom: 2rem;
|
margin-bottom: 2rem;
|
||||||
}
|
font-size: 0.95rem;
|
||||||
|
|
||||||
.settings-section h3 {
|
|
||||||
font-size: 1rem;
|
|
||||||
font-weight: bold;
|
|
||||||
color: #666;
|
|
||||||
margin: 0 0 0.5rem;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.5px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-section .form-group {
|
.settings-section .form-group {
|
||||||
margin-top: 1rem;
|
margin-top: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-section p a {
|
.about-section {
|
||||||
color: #007bff;
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-section a {
|
||||||
|
color: var(--link-color);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-section p a:hover {
|
.about-section a:visited {
|
||||||
|
color: var(--link-color-visited);
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-section a:hover {
|
||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -323,7 +512,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.btn-primary {
|
.btn-primary {
|
||||||
background: #007bff;
|
background: var(--link-color);
|
||||||
color: white;
|
color: white;
|
||||||
border: none;
|
border: none;
|
||||||
padding: 0.75rem;
|
padding: 0.75rem;
|
||||||
@@ -352,7 +541,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.meta-info a {
|
.meta-info a {
|
||||||
color: #007bff;
|
color: var(--link-color);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -360,24 +549,34 @@ body {
|
|||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
|
|
||||||
.link-list {
|
.sidebar-content table {
|
||||||
list-style: none;
|
width: 100%;
|
||||||
padding: 0;
|
border-collapse: collapse;
|
||||||
margin: 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.link-list li {
|
.sidebar-content table th,
|
||||||
margin-bottom: 0.5rem;
|
.sidebar-content table td {
|
||||||
|
padding: 0.5rem 0;
|
||||||
|
text-align: left;
|
||||||
}
|
}
|
||||||
|
|
||||||
.link-list a {
|
.sidebar-content table th {
|
||||||
color: #007bff;
|
font-size: 0.75rem;
|
||||||
text-decoration: none;
|
font-weight: bold;
|
||||||
font-size: 0.95rem;
|
text-transform: uppercase;
|
||||||
|
color: #898989;
|
||||||
}
|
}
|
||||||
|
|
||||||
.link-list a:hover {
|
.sidebar-content table td {
|
||||||
text-decoration: underline;
|
border-bottom: 1px solid #f9f9f9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-content table tr:last-child td {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
abbr[title] {
|
||||||
|
text-decoration: underline dotted;
|
||||||
}
|
}
|
||||||
|
|
||||||
.places-list {
|
.places-list {
|
||||||
@@ -400,7 +599,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.place-item:hover {
|
.place-item:hover {
|
||||||
background: #eee;
|
background: var(--hover-bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.place-name {
|
.place-name {
|
||||||
@@ -475,6 +674,12 @@ body {
|
|||||||
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;
|
||||||
@@ -496,7 +701,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.btn-blue {
|
.btn-blue {
|
||||||
background: #007bff;
|
background: var(--link-color);
|
||||||
color: white;
|
color: white;
|
||||||
border: none;
|
border: none;
|
||||||
}
|
}
|
||||||
@@ -606,6 +811,17 @@ body {
|
|||||||
|
|
||||||
/* Icons */
|
/* Icons */
|
||||||
|
|
||||||
|
.app-logo-icon {
|
||||||
|
display: inline-flex;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-logo-icon svg {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
span.icon {
|
span.icon {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
}
|
}
|
||||||
@@ -658,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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -730,15 +946,14 @@ span.icon {
|
|||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Sidebar is open (Desktop: Left 300px) */
|
/* Sidebar is open (Desktop: Left var(--sidebar-width)) */
|
||||||
|
|
||||||
/* We want to center in the remaining space (width - 300px) */
|
/* We want to center in the remaining space (width - var(--sidebar-width)) */
|
||||||
|
|
||||||
/* Center X = 300 + (width - 300) / 2 = 300 + width/2 - 150 = width/2 + 150 */
|
/* Center X = var(--sidebar-width) + (width - var(--sidebar-width)) / 2 = var(--sidebar-width)/2 + 50% */
|
||||||
|
|
||||||
/* So shift left by 150px from center */
|
|
||||||
.map-container.sidebar-open .map-crosshair {
|
.map-container.sidebar-open .map-crosshair {
|
||||||
left: calc(50% + 150px);
|
left: calc(50% + var(--sidebar-width) / 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (width <= 768px) {
|
@media (width <= 768px) {
|
||||||
@@ -946,7 +1161,7 @@ button.create-place {
|
|||||||
|
|
||||||
.search-result-item:hover,
|
.search-result-item:hover,
|
||||||
.search-result-item:focus {
|
.search-result-item:focus {
|
||||||
background: #f5f5f5;
|
background: var(--hover-bg);
|
||||||
outline: none;
|
outline: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1011,7 +1226,7 @@ button.create-place {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.place-lists-manager .list-item:hover {
|
.place-lists-manager .list-item:hover {
|
||||||
background: #f8f9fa;
|
background: var(--hover-bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.place-lists-manager label {
|
.place-lists-manager label {
|
||||||
@@ -1026,7 +1241,7 @@ button.create-place {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.place-lists-manager input[type='checkbox'] {
|
.place-lists-manager input[type='checkbox'] {
|
||||||
accent-color: #007bff;
|
accent-color: var(--link-color);
|
||||||
width: 16px;
|
width: 16px;
|
||||||
height: 16px;
|
height: 16px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
@@ -1046,3 +1261,57 @@ 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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import Component from '@glimmer/component';
|
|||||||
import { pageTitle } from 'ember-page-title';
|
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 SettingsPane from '#components/settings-pane';
|
import AppMenu from '#components/app-menu/index';
|
||||||
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';
|
||||||
@@ -14,7 +14,7 @@ export default class ApplicationComponent extends Component {
|
|||||||
@service mapUi;
|
@service mapUi;
|
||||||
@service router;
|
@service router;
|
||||||
|
|
||||||
@tracked isSettingsOpen = false;
|
@tracked isAppMenuOpen = false;
|
||||||
|
|
||||||
get isSidebarOpen() {
|
get isSidebarOpen() {
|
||||||
// We consider the sidebar "open" if we are in search or place routes.
|
// We consider the sidebar "open" if we are in search or place routes.
|
||||||
@@ -34,19 +34,19 @@ export default class ApplicationComponent extends Component {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@action
|
@action
|
||||||
toggleSettings() {
|
toggleAppMenu() {
|
||||||
this.isSettingsOpen = !this.isSettingsOpen;
|
this.isAppMenuOpen = !this.isAppMenuOpen;
|
||||||
}
|
}
|
||||||
|
|
||||||
@action
|
@action
|
||||||
closeSettings() {
|
closeAppMenu() {
|
||||||
this.isSettingsOpen = false;
|
this.isAppMenuOpen = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@action
|
@action
|
||||||
handleOutsideClick() {
|
handleOutsideClick() {
|
||||||
if (this.isSettingsOpen) {
|
if (this.isAppMenuOpen) {
|
||||||
this.closeSettings();
|
this.closeAppMenu();
|
||||||
} else if (this.router.currentRouteName === 'search') {
|
} else if (this.router.currentRouteName === 'search') {
|
||||||
this.router.transitionTo('index');
|
this.router.transitionTo('index');
|
||||||
} else if (this.router.currentRouteName === 'place') {
|
} else if (this.router.currentRouteName === 'place') {
|
||||||
@@ -65,7 +65,7 @@ export default class ApplicationComponent extends Component {
|
|||||||
<template>
|
<template>
|
||||||
{{pageTitle "Marco"}}
|
{{pageTitle "Marco"}}
|
||||||
|
|
||||||
<AppHeader @onToggleMenu={{this.toggleSettings}} />
|
<AppHeader @onToggleMenu={{this.toggleAppMenu}} />
|
||||||
|
|
||||||
<div
|
<div
|
||||||
id="rs-widget-container"
|
id="rs-widget-container"
|
||||||
@@ -81,12 +81,12 @@ export default class ApplicationComponent extends Component {
|
|||||||
{{/if}}
|
{{/if}}
|
||||||
|
|
||||||
<Map
|
<Map
|
||||||
@isSidebarOpen={{or this.isSidebarOpen this.isSettingsOpen}}
|
@isSidebarOpen={{or this.isSidebarOpen this.isAppMenuOpen}}
|
||||||
@onOutsideClick={{this.handleOutsideClick}}
|
@onOutsideClick={{this.handleOutsideClick}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{{#if this.isSettingsOpen}}
|
{{#if this.isAppMenuOpen}}
|
||||||
<SettingsPane @onClose={{this.closeSettings}} />
|
<AppMenu @onClose={{this.closeAppMenu}} />
|
||||||
{{/if}}
|
{{/if}}
|
||||||
|
|
||||||
{{outlet}}
|
{{outlet}}
|
||||||
|
|||||||
@@ -5,8 +5,11 @@ import checkSquare from 'feather-icons/dist/icons/check-square.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 facebook from 'feather-icons/dist/icons/facebook.svg?raw';
|
import facebook from 'feather-icons/dist/icons/facebook.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 home from 'feather-icons/dist/icons/home.svg?raw';
|
import home from 'feather-icons/dist/icons/home.svg?raw';
|
||||||
|
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';
|
||||||
@@ -24,19 +27,30 @@ 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 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 camera from '@waysidemapping/pinhead/dist/icons/camera.svg?raw';
|
||||||
|
import cupAndSaucer from '@waysidemapping/pinhead/dist/icons/cup_and_saucer.svg?raw';
|
||||||
|
import forkAndKnife from '@waysidemapping/pinhead/dist/icons/fork_and_knife.svg?raw';
|
||||||
|
import personSleepingInBed from '@waysidemapping/pinhead/dist/icons/person_sleeping_in_bed.svg?raw';
|
||||||
|
import shoppingBasket from '@waysidemapping/pinhead/dist/icons/shopping_basket.svg?raw';
|
||||||
import wikipedia from '../icons/wikipedia.svg?raw';
|
import wikipedia from '../icons/wikipedia.svg?raw';
|
||||||
|
|
||||||
const ICONS = {
|
const ICONS = {
|
||||||
'arrow-left': arrowLeft,
|
'arrow-left': arrowLeft,
|
||||||
activity,
|
activity,
|
||||||
bookmark,
|
bookmark,
|
||||||
|
camera,
|
||||||
'check-square': checkSquare,
|
'check-square': checkSquare,
|
||||||
clock,
|
clock,
|
||||||
|
'cup-and-saucer': cupAndSaucer,
|
||||||
edit,
|
edit,
|
||||||
facebook,
|
facebook,
|
||||||
|
gift,
|
||||||
globe,
|
globe,
|
||||||
|
heart,
|
||||||
home,
|
home,
|
||||||
|
info,
|
||||||
instagram,
|
instagram,
|
||||||
|
'fork-and-knife': forkAndKnife,
|
||||||
'log-in': logIn,
|
'log-in': logIn,
|
||||||
'log-out': logOut,
|
'log-out': logOut,
|
||||||
mail,
|
mail,
|
||||||
@@ -44,11 +58,13 @@ const ICONS = {
|
|||||||
'map-pin': mapPin,
|
'map-pin': mapPin,
|
||||||
menu,
|
menu,
|
||||||
navigation,
|
navigation,
|
||||||
|
'person-sleeping-in-bed': personSleepingInBed,
|
||||||
phone,
|
phone,
|
||||||
plus,
|
plus,
|
||||||
server,
|
server,
|
||||||
search,
|
search,
|
||||||
settings,
|
settings,
|
||||||
|
'shopping-basket': shoppingBasket,
|
||||||
target,
|
target,
|
||||||
user,
|
user,
|
||||||
wikipedia,
|
wikipedia,
|
||||||
@@ -56,6 +72,19 @@ const ICONS = {
|
|||||||
zap,
|
zap,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const FILLED_ICONS = [
|
||||||
|
'fork-and-knife',
|
||||||
|
'wikipedia',
|
||||||
|
'cup-and-saucer',
|
||||||
|
'shopping-basket',
|
||||||
|
'camera',
|
||||||
|
'person-sleeping-in-bed',
|
||||||
|
];
|
||||||
|
|
||||||
export function getIcon(name) {
|
export function getIcon(name) {
|
||||||
return ICONS[name];
|
return ICONS[name];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isIconFilled(name) {
|
||||||
|
return FILLED_ICONS.includes(name);
|
||||||
|
}
|
||||||
|
|||||||
62
app/utils/poi-categories.js
Normal file
62
app/utils/poi-categories.js
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
// This configuration defines the "Quick Search" categories available in the UI.
|
||||||
|
//
|
||||||
|
// Structure:
|
||||||
|
// - id: The URL slug used for routing (e.g. ?category=restaurants)
|
||||||
|
// - label: The human-readable name displayed in the UI
|
||||||
|
// - icon: The icon name (must be registered in app/utils/icons.js)
|
||||||
|
// - filter: An array of Overpass QL query parts.
|
||||||
|
// - Each string in the array is an independent query condition.
|
||||||
|
// - Multiple strings act as an OR condition (union of results).
|
||||||
|
|
||||||
|
export const POI_CATEGORIES = [
|
||||||
|
{
|
||||||
|
id: 'restaurants',
|
||||||
|
label: 'Restaurants',
|
||||||
|
icon: 'fork-and-knife',
|
||||||
|
filter: ['["amenity"~"^(restaurant|fast_food|food_court|pub|cafe)$"]'],
|
||||||
|
types: ['node', 'way'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'coffee',
|
||||||
|
label: 'Coffee',
|
||||||
|
icon: 'cup-and-saucer',
|
||||||
|
filter: [
|
||||||
|
'["amenity"~"^(cafe|ice_cream)$"]',
|
||||||
|
'["shop"~"^(coffee|tea)$"]',
|
||||||
|
'["cuisine"~"coffee_shop"]',
|
||||||
|
],
|
||||||
|
types: ['node', 'way'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'groceries',
|
||||||
|
label: 'Groceries',
|
||||||
|
icon: 'shopping-basket',
|
||||||
|
filter: [
|
||||||
|
'["shop"~"^(supermarket|convenience|grocery|greengrocer|bakery|butcher|deli|farm|seafood)$"]',
|
||||||
|
],
|
||||||
|
types: ['node', 'way'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'things-to-do',
|
||||||
|
label: 'Things to do',
|
||||||
|
icon: 'camera',
|
||||||
|
filter: [
|
||||||
|
'["tourism"~"^(museum|gallery|attraction|viewpoint|zoo|theme_park|aquarium|artwork)$"]',
|
||||||
|
'["amenity"~"^(cinema|theatre|arts_centre|planetarium)$"]',
|
||||||
|
'["leisure"~"^(sports_centre|stadium|water_park)$"]',
|
||||||
|
'["historic"]',
|
||||||
|
],
|
||||||
|
types: ['node', 'way', 'relation'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'accommodation',
|
||||||
|
label: 'Hotels',
|
||||||
|
icon: 'person-sleeping-in-bed',
|
||||||
|
filter: ['["tourism"~"^(hotel|hostel|motel)$"]'],
|
||||||
|
types: ['node', 'way', 'relation'],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function getCategoryById(id) {
|
||||||
|
return POI_CATEGORIES.find((c) => c.id === id);
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "marco",
|
"name": "marco",
|
||||||
"version": "1.14.0",
|
"version": "1.16.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "Unhosted maps app",
|
"description": "Unhosted maps app",
|
||||||
"repository": {
|
"repository": {
|
||||||
@@ -21,7 +21,7 @@
|
|||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "vite build --outDir release/",
|
"build": "vite build --outDir release/",
|
||||||
"build:icons": "for size in 32 48 144 180 192 512; do if [ \"$size\" -le 64 ]; then magick public/icons/icon.svg -define svg:remove-groups=map-grid -resize ${size}x${size} public/icons/icon-${size}.png; else rsvg-convert -w $size -h $size public/icons/icon.svg -o public/icons/icon-${size}.png; fi; done && rsvg-convert -w 512 -h 512 public/icons/icon.svg -o public/icons/icon-maskable.png",
|
"build:icons": "cp public/icons/icon-rounded.svg app/icons/icon-rounded.svg && for size in 32 48 144 180 192 512; do if [ \"$size\" -le 64 ]; then magick public/icons/icon.svg -define svg:remove-groups=map-grid -resize ${size}x${size} public/icons/icon-${size}.png; else rsvg-convert -w $size -h $size public/icons/icon.svg -o public/icons/icon-${size}.png; fi; done && rsvg-convert -w 512 -h 512 public/icons/icon.svg -o public/icons/icon-maskable.png",
|
||||||
"format": "prettier . --cache --write",
|
"format": "prettier . --cache --write",
|
||||||
"lint": "concurrently \"pnpm:lint:*(!fix)\" --names \"lint:\" --prefixColors auto",
|
"lint": "concurrently \"pnpm:lint:*(!fix)\" --names \"lint:\" --prefixColors auto",
|
||||||
"lint:css": "stylelint \"**/*.css\"",
|
"lint:css": "stylelint \"**/*.css\"",
|
||||||
@@ -35,7 +35,7 @@
|
|||||||
"lint:js:fix": "eslint . --fix",
|
"lint:js:fix": "eslint . --fix",
|
||||||
"start": "vite",
|
"start": "vite",
|
||||||
"test": "vite build --mode development && testem ci --port 0",
|
"test": "vite build --mode development && testem ci --port 0",
|
||||||
"preversion": "pnpm test",
|
"preversion": "pnpm lint && pnpm test",
|
||||||
"version": "pnpm build && git add release/"
|
"version": "pnpm build && git add release/"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -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
2
release/assets/main-C4F17h3W.js
Normal file
2
release/assets/main-C4F17h3W.js
Normal file
File diff suppressed because one or more lines are too long
1
release/assets/main-CKp1bFPU.css
Normal file
1
release/assets/main-CKp1bFPU.css
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
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-GynTgP18.js"></script>
|
<script type="module" crossorigin src="/assets/main-C4F17h3W.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/main-BT0n1kYB.css">
|
<link rel="stylesheet" crossorigin href="/assets/main-CKp1bFPU.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -155,4 +155,67 @@ 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');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,13 +1,25 @@
|
|||||||
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 {}
|
||||||
|
|
||||||
|
this.owner.register('service:photon', MockPhotonService);
|
||||||
|
this.owner.register('service:router', MockRouterService);
|
||||||
|
this.owner.register('service:map-ui', MockMapUiService);
|
||||||
|
this.owner.register('service:map', MockMapService);
|
||||||
|
|
||||||
await render(
|
await render(
|
||||||
<template><AppHeader @onToggleMenu={{this.noop}} /></template>
|
<template><AppHeader @onToggleMenu={{this.noop}} /></template>
|
||||||
);
|
);
|
||||||
@@ -16,4 +28,39 @@ module('Integration | Component | app-header', function (hooks) {
|
|||||||
assert.dom('.search-box').exists('Search box is present in the header');
|
assert.dom('.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 {}
|
||||||
|
|
||||||
|
this.owner.register('service:photon', MockPhotonService);
|
||||||
|
this.owner.register('service:router', MockRouterService);
|
||||||
|
this.owner.register('service:map-ui', MockMapUiService);
|
||||||
|
this.owner.register('service:map', MockMapService);
|
||||||
|
|
||||||
|
await render(
|
||||||
|
<template><AppHeader @onToggleMenu={{this.noop}} /></template>
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.dom('.header-center').doesNotHaveClass('searching');
|
||||||
|
|
||||||
|
await fillIn('.search-input', 'test');
|
||||||
|
|
||||||
|
assert.dom('.header-center').hasClass('searching');
|
||||||
|
|
||||||
|
await fillIn('.search-input', '');
|
||||||
|
|
||||||
|
assert.dom('.header-center').doesNotHaveClass('searching');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
56
tests/integration/components/category-chips-test.gjs
Normal file
56
tests/integration/components/category-chips-test.gjs
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import { module, test } from 'qunit';
|
||||||
|
import { setupRenderingTest } from 'marco/tests/helpers';
|
||||||
|
import { render, click } from '@ember/test-helpers';
|
||||||
|
import CategoryChips from 'marco/components/category-chips';
|
||||||
|
import Service from '@ember/service';
|
||||||
|
import { POI_CATEGORIES } from 'marco/utils/poi-categories';
|
||||||
|
|
||||||
|
module('Integration | Component | category-chips', function (hooks) {
|
||||||
|
setupRenderingTest(hooks);
|
||||||
|
|
||||||
|
test('it renders the correct number of chips', async function (assert) {
|
||||||
|
class MockRouterService extends Service {}
|
||||||
|
class MockMapUiService extends Service {}
|
||||||
|
|
||||||
|
this.owner.register('service:router', MockRouterService);
|
||||||
|
this.owner.register('service:map-ui', MockMapUiService);
|
||||||
|
|
||||||
|
await render(<template><CategoryChips /></template>);
|
||||||
|
|
||||||
|
assert.dom('.category-chip').exists({ count: 5 });
|
||||||
|
|
||||||
|
// Check for some expected labels
|
||||||
|
assert.dom(this.element).includesText('Restaurants');
|
||||||
|
assert.dom(this.element).includesText('Coffee');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('clicking a chip triggers the @onSelect action', async function (assert) {
|
||||||
|
let selectedCategory;
|
||||||
|
this.handleSelect = (category) => {
|
||||||
|
selectedCategory = category;
|
||||||
|
};
|
||||||
|
|
||||||
|
class MockRouterService extends Service {
|
||||||
|
transitionTo() {}
|
||||||
|
}
|
||||||
|
class MockMapUiService extends Service {}
|
||||||
|
|
||||||
|
this.owner.register('service:router', MockRouterService);
|
||||||
|
this.owner.register('service:map-ui', MockMapUiService);
|
||||||
|
|
||||||
|
await render(
|
||||||
|
<template><CategoryChips @onSelect={{this.handleSelect}} /></template>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Find the chip for "Coffee"
|
||||||
|
const coffeeCategory = POI_CATEGORIES.find((c) => c.id === 'coffee');
|
||||||
|
const chip = Array.from(
|
||||||
|
this.element.querySelectorAll('.category-chip')
|
||||||
|
).find((el) => el.textContent.includes(coffeeCategory.label));
|
||||||
|
|
||||||
|
await click(chip);
|
||||||
|
|
||||||
|
assert.strictEqual(selectedCategory.id, 'coffee');
|
||||||
|
assert.strictEqual(selectedCategory.label, 'Coffee');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -100,7 +100,7 @@ module('Integration | Component | search-box', function (hooks) {
|
|||||||
.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));
|
.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'
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user