Compare commits

..

1 Commits

Author SHA1 Message Date
ac089286bd 1.13.0 2026-03-11 16:14:04 +04:00
61 changed files with 594 additions and 4179 deletions

View File

@@ -1,14 +0,0 @@
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

View File

@@ -1,13 +0,0 @@
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 }}

View File

@@ -1,74 +1,156 @@
# Project Status: Marco # Project Status: Marco
**Last Updated:** Wed Mar 18 2026 **Last Updated:** Tue Feb 24 2026
## Project Context ## Project Context
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**. 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.
## What We Have Done ## What We Have Done
### 1. Map Integration ### 1. Map Integration
- **Vector Tiles:** Using **OpenFreeMap Liberty** style with a hybrid click handler (Visual Tiles + Overpass API fallback). - Set up OpenLayers in `app/components/map.gjs` (class-based component).
- **Smart Interaction:** - Switched tiles to **OpenFreeMap Liberty** style (supports vector POIs).
- **Hit Tolerance:** 10px buffer for easier mobile tapping. - Implemented a hybrid click handler:
- **Auto-Pan:** Selected pins automatically center in the visible area (respecting bottom sheets/sidebars). - Detects clicks on visual vector tiles.
- **Smart Zoom:** `zoomToBbox` fits complex geometries (ways/relations) with dynamic padding, only zooming out to fit. - Falls back to fetching authoritative data from an **Overpass API** service.
- **Visuals:** Custom "Red Pin" overlay with drop animation. Selected OSM ways/relations show distinct blue outlines. - **Logic Upgrade:** Map intelligently detects if _any_ sidebar/pane is open and handles outside clicks to close them instead of initiating new searches.
- **Geolocation:** Robust "Locate Me" with dynamic zoom and accuracy visualization. - **Optimization:** Added **10px hit tolerance** for easier tapping on mobile devices.
- **Visuals:** Increased bookmark marker size (Radius 9px) and added a subtle drop shadow.
- **Feedback:** Implemented a "pulse" animation (via OpenLayers Overlay) at the click location to visualize the search radius (30m/50m).
- **Mobile UX:**
- **Touch:** Disabled browser tap highlights (`-webkit-tap-highlight-color: transparent`) to prevent blue flashing on Android.
- **Scroll:** Disabled "pull-to-refresh" (`overscroll-behavior: none`) on the body to prevent accidental reloads while keeping the sidebar scrollable (`contain`).
- **Auto-Pan:** On mobile screens, if a selected pin is obscured by the bottom sheet, the map automatically pans to center the pin in the visible top half of the screen.
- **Controls:** Fixed positioning of "Locate" and "Rotate" buttons on mobile by correcting CSS `inset` syntax.
- **iOS Polish:**
- Prevented input auto-zoom by ensuring `.form-control` font size is `1rem` (16px).
- Added `-webkit-text-size-adjust: 100%` to prevent text inflation on rotation.
- Set base `body` font size to `16px`.
- **Geolocation ("Locate Me"):**
- Implemented a "Locate Me" button with robust tracking logic.
- **Dynamic Zoom:** Automatically zooms to a level where the accuracy circle covers ~10% of the map (fallback logic handles missing accuracy data).
- **Smart Pulse:** Displays a pulsing blue circle during the search phase.
- **Auto-Stop:** Pulse and tracking automatically stop when high accuracy (≤20m) is achieved or after a 10s timeout.
- **Persistence:** Saves and restores map center and zoom level using `localStorage` (key: `marco:map-view`).
- **Controls:** Enabled standard OpenLayers Rotate control (re-north) and custom Locate control.
- **Pin Animation:** Selected pins are highlighted with a custom **Red Pin** overlay that drops in with an animation. The center dot is styled as a solid dark red circle (`#b31412`).
- **Smart Zoom:** Implemented `zoomToBbox` to automatically fit complex geometries (ways/relations) within the visible viewport.
- **Dynamic Padding:** Calculates padding based on active UI elements (Sidebar on Desktop, Bottom Sheet on Mobile) to ensure the geometry is perfectly centered in the _visible_ map area.
- **Data Processing:** `OsmService` now calculates bounding boxes for ways and relations by aggregating member node coordinates.
- **Geometry Rendering:**
- **Outlines:** Implemented distinct blue outlines for selected OSM `ways` (Polygons) and `relations` (MultiLineStrings/Polygons) to clearly visualize boundaries.
- **Data Fetching:** Enhanced routing to fetch full geometry data on-demand if the initial search result (e.g., from Photon) lacks it, ensuring outlines are always available.
### 2. RemoteStorage Module (`@remotestorage/module-places`) ### 2. RemoteStorage Module (`@remotestorage/module-places`)
- **Custom Module:** Handles `place` objects with Geohash-based partitioning (`<2-char>/<2-char>/<id>`). - Created a custom TypeScript module in `vendor/remotestorage-module-places/`.
- **Optimization:** Supports efficient spatial querying via prefix loading. - **Schema:** `place` object containing `id` (ULID), `title`, `lat`, `lon`, `geohash`, `osmId`, `url`, etc.
- **Lists Support:** Manages collection-based organization (e.g., "To Visit", "Favorites"). - **Storage Path:** Nested `<2-char>/<2-char>/<id>` (based on geohash) for scalability.
- **API:**
- `getPlaces(prefixes?)`: efficient partial loading of specific sectors (or full recursive scan if no prefixes provided).
- Uses `getListing` for directory traversal and `getAll` for object retrieval.
- configured with `maxAge: false` to ensure data freshness.
- **Dependencies:** Uses `ulid` and `latlon-geohash` internally.
### 3. App Infrastructure ### 3. App Infrastructure & Build
- **Services:** - **Services:**
- `storage.js`: Manages RemoteStorage, caching, and the new **Lists** feature (`to-go`, `to-do`). - `storage.js`: Initializes RemoteStorage, claims access, enables caching, and sets up the widget. Consumes the new `getPlaces` API.
- `osm.js`: Fetches/caches POIs from Overpass API (configurable endpoints). - **Optimization:** Implemented **Debounced Reload** (200ms) for bookmark updates to handle rapid change events efficiently.
- `settings.js`: Persists user preferences (e.g., API provider). - **Optimization:** Correctly handles deletion/updates by clearing stale data for reloaded geohash sectors.
- `osm.js`: Fetches nearby POIs from Overpass API.
- **Configurable:** Now supports dynamic API endpoints via `SettingsService`.
- **Reliability:** Implemented `fetchWithRetry` to handle HTTP 504/502/503 timeouts and 429 rate limits, in addition to network errors.
- **Caching:** Implemented in-memory cache for repeated `getNearbyPois` requests (same lat/lon/radius) to enable instant "Back" navigation.
- `settings.js`: Manages user preferences (currently Overpass API provider) persisted to `localStorage`.
- **UI Components:** - **UI Components:**
- **Responsive Layout:** Sidebar transforms into a Bottom Sheet on mobile. - `places-sidebar.gjs`: Displays a list of nearby POIs.
- **Place Details:** Rich info (Address, Socials, Opening Hours) with distinct "Actions" and "Meta" sections. - **Layout:** Responsive design that transforms into a **Bottom Sheet** (50% height) on mobile screens (`<=768px`) with rounded corners and upward shadow.
- **App Menu:** Comprehensive settings and about section, implemented as a secondary sidebar. - `place-details.gjs`: Dedicated component for displaying rich place information.
- **CI/CD:** Gitea Actions for automated testing and release drafting. - **Features:** Icons (via `feather-icons`), Address, Phone, Website, Opening Hours, Cuisine, Wikipedia.
- **Layout:** Polished UI with distinct sections for Actions and Meta info.
- `app-header.gjs`: Transparent header with "Menu" button (Settings) and User Avatar (Login).
- `settings-pane.gjs`: Sidebar component for app info ("About" section) and settings.
- **Features:** Dropdown to select Overpass API provider (bke.ro, overpass-api.de, private.coffee).
- **Mobile:** Renders as a 2/3 height bottom sheet on mobile.
- **Z-Index:** Configured to overlay the Places sidebar correctly (`z-index: 3200`).
- **Geo Utils:**
- `app/utils/geo.js`: Haversine distance calculations.
- `app/utils/geohash-coverage.js`: Logic to calculate required 4-char geohash prefixes for a given bounding box.
- **Format Utils:**
- `app/utils/format-text.js` & `humanize-osm-tag` helper: Standardized logic (Title Case, space replacement) for displaying OSM tags like `guest_house` -> "Guest House".
- **Tag refinement:** Improved logic for handling generic tags (e.g., `building=yes`). The UI now intelligently displays the key ("Building") instead of the value ("Yes") for better readability.
- **Localization:** Added basic `navigator.languages` support to `getLocalizedName` for preferring local names when available.
- **Build & DevOps:**
- **Icon Generation:** Added `build:icons` script using `magick` and `rsvg-convert` to automate PNG generation from SVG.
- **Dependencies:** Documented system requirements (ImageMagick, librsvg) in `README.md`.
- **Ember CLI:** Added as dev dependency to support generator commands.
- **License:** Added AGPLv3 license.
### 4. Routing & Architecture ### 4. Routing & Architecture (Refactored)
- **URL-Driven:** `/search` (list) and `/place/:id` (details) routes. - **URL-Driven Architecture:** Moved from service-based state to proper route-based state management.
- **Smart Navigation:** - `/search?lat=...&lon=...&q=...`: Displays search results list.
- Direct hits redirect to details. - `/place/:place_id`: Displays details for a specific place (OSM POI or Bookmark).
- Search results automatically resolve to existing **Bookmarks**. - **Heuristic Navigation:** The `search` route implements "visual click matching" logic. If a search yields a direct match (exact name or very close proximity), it automatically redirects to the `/place/` route, skipping the list view.
- "Back" navigation returns to cached search results instantly. - **Back Button Support:** Browser history works correctly. Navigating "Back" from a place returns to the cached search results instantly without network requests.
- **Explicit URLs:** Routes support specific OSM entities via `/place/osm:node:<id>` and `/place/osm:way:<id>`, distinguishing them from local bookmarks (ULIDs).
- **Smart Linking:** The `showPlaces` action intercepts search results and automatically resolves them to existing **Bookmarks** if a match is found (via `storage.findPlaceById`). This ensures the app navigates to the persistent Bookmark URL (ULID) and correctly reflects the "Saved" status in the UI instead of treating it as a new generic OSM place.
- **Data Normalization:** Refactored `OsmService` to return normalized objects (`osmTags`, `osmType`) for all queries. This ensures consistent data structures between fresh Overpass results and saved bookmarks throughout the app.
### 5. Features ### 5. Creation & Editing Workflow
- **Search:** Typo-tolerant **Photon API** integration with location bias and debounce. - **Create Place:**
- **Creation & Editing:** - Implemented `/place/new` route for creating new private places.
- "Crosshair" mode for precise location picking. - **UX:** Map displays a central crosshair for precise location selection.
- Edit Title/Description for saved places. - **Mobile Optimization:**
- **Lists:** Users can add places to default lists ("To Go", "To Do") directly from the details view. - Disabled map inertia (`kinetic: false`) to ensure the map stops exactly where the finger releases.
- **Socials:** Place details now include Email, Facebook, and Instagram links. - `PlaceEditForm` conditionally disables autofocus on mobile screens (`<= 768px`) to prevent the onscreen keyboard from obscuring the map view immediately.
- **Data Sync:** Auto-refreshes OSM data (coords/tags) for saved places on view, preserving custom titles. - Responsive crosshair sizing (48px desktop / 24px mobile).
- **Persistence:** Form data (Title, Description) and Map coordinates are securely saved to RemoteStorage via `storage.storePlace`.
### 6. Search Functionality
- **Provider:** Integrated **Photon API** (by Komoot) via `app/services/photon.js` for high-quality, typo-tolerant OpenStreetMap search.
- **UI:** `SearchBoxComponent` implements a responsive search bar with instant autocomplete.
- **Debounced Input:** 300ms delay to prevent excessive API calls.
- **Location Bias:** Automatically biases search results towards the current map center to show relevant local places first.
- **Direct Navigation:** Selecting a result with a valid OSM ID navigates directly to the specific place details (`/place/osm:type:id`).
- **Resilience:** Implemented retry logic (exponential backoff/fixed delay) for network errors and rate limits (429).
- **Data Normalization:** Search results are normalized to match the internal POI schema, ensuring consistent rendering across Search and Map views.
## Current State ## Current State
- **Repo:** Runs via `pnpm start`. - **Repo:** The app runs via `pnpm start`.
- **Workflow:** - **Workflow:**
1. **Explore:** Pan/Zoom loads bookmarks from RemoteStorage. 1. User pans map -> `moveend` triggers `storage.loadPlacesInBounds`.
2. **Search:** Query via Photon -> List or Direct Result. 2. User clicks map -> Route transition to `/search` -> "Pulse" animation -> hybrid hit detection (Visual Tile vs Overpass).
3. **View:** Details pane (Sidebar/Bottom Sheet) shows rich info + social links. 3. **Navigation:**
4. **Action:** - If direct match: Redirect to `/place/:id`.
- **Save:** Persist to RemoteStorage. - If multiple results: Show `/search` list view.
- **Organize:** Add to "To Go" / "To Do" lists. 4. Sidebar displays details via `<PlaceDetails>` component (Bottom sheet on mobile).
- **Edit:** Custom Title/Description. 5. **Creation:** User clicks "Create Place" -> Enters creation mode (crosshair) -> Positions map -> Enters details -> Save.
5. **Sync:** Background check updates OSM data if changed. 6. **Persistence:** RemoteStorage change event -> Debounced reload updates the map reactive-ly.
7. **Editing:** User can edit the Title and Description of saved bookmarks via an "Edit" button in the details view.
8. **Settings:** User can change the Overpass API provider via the new Settings menu.
## Next Steps ## Files Currently in Focus
1. **Testing:** Add automated tests for the new Lists logic and Geohash coverage. - `app/services/osm.js`
2. **Performance:** Monitor with large datasets. - `app/components/map.gjs`
3. **Refinement:** Polish list UI and interactions. - `app/routes/place.js`
- `app/utils/osm.js`
## Next Steps & Pending Tasks
1. **Linting & Code Quality:** Fix remaining CSS errors and address unused variables/runloop usage.
2. **Testing:** Add automated tests for the geohash coverage, retry logic, and new editing features.
3. **Performance:** Monitor performance with large datasets (thousands of bookmarks).
## Technical Constraints
- **Template Style:** Strict Mode GJS (`<template>`).
- **Package Manager:** `pnpm` for the main app, `npm` for the vendor module.
- **Visuals:** No Tailwind/Bootstrap; using custom CSS in `app/styles/app.css`.

View File

@@ -6,17 +6,10 @@ import { on } from '@ember/modifier';
import Icon from '#components/icon'; import Icon from '#components/icon';
import UserMenu from '#components/user-menu'; import UserMenu from '#components/user-menu';
import SearchBox from '#components/search-box'; import SearchBox from '#components/search-box';
import CategoryChips from '#components/category-chips';
export default class AppHeaderComponent extends Component { export default class AppHeaderComponent extends Component {
@service storage; @service storage;
@service settings;
@tracked isUserMenuOpen = false; @tracked isUserMenuOpen = false;
@tracked searchQuery = '';
get hasQuery() {
return !!this.searchQuery;
}
@action @action
toggleUserMenu() { toggleUserMenu() {
@@ -28,34 +21,12 @@ 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 <SearchBox @onToggleMenu={{@onToggleMenu}} />
@query={{this.searchQuery}}
@onToggleMenu={{@onToggleMenu}}
@onQueryChange={{this.handleQueryChange}}
/>
</div> </div>
{{#if this.settings.showQuickSearchButtons}}
<div class="header-center {{if this.hasQuery 'searching'}}">
<CategoryChips @onSelect={{this.handleChipSelect}} />
</div>
{{/if}}
<div class="header-right"> <div class="header-right">
<div class="user-menu-container"> <div class="user-menu-container">
<button <button

View File

@@ -1,168 +0,0 @@
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>

View File

@@ -1,36 +0,0 @@
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>

View File

@@ -1,38 +0,0 @@
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>
}

View File

@@ -1,129 +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 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
toggleQuickSearchButtons(event) {
this.settings.updateShowQuickSearchButtons(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="show-quick-search">Quick search buttons visible</label>
<select
id="show-quick-search"
class="form-control"
{{on "change" this.toggleQuickSearchButtons}}
>
<option
value="true"
selected={{if this.settings.showQuickSearchButtons "selected"}}
>
Yes
</option>
<option
value="false"
selected={{unless
this.settings.showQuickSearchButtons
"selected"
}}
>
No
</option>
</select>
</div>
<div class="form-group">
<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>
}

View File

@@ -1,57 +0,0 @@
import Component from '@glimmer/component';
import { service } from '@ember/service';
import { action } from '@ember/object';
import { on } from '@ember/modifier';
import { fn } from '@ember/helper';
import Icon from '#components/icon';
import { POI_CATEGORIES } from '../utils/poi-categories';
import { eq, and } from 'ember-truth-helpers';
export default class CategoryChipsComponent extends Component {
@service router;
@service mapUi;
get categories() {
return POI_CATEGORIES;
}
@action
searchCategory(category) {
// If passed an onSelect action, call it (e.g. to clear search box)
if (this.args.onSelect) {
this.args.onSelect(category);
}
let queryParams = { category: category.id, q: null };
if (this.mapUi.currentCenter) {
const { lat, lon } = this.mapUi.currentCenter;
queryParams.lat = parseFloat(lat).toFixed(4);
queryParams.lon = parseFloat(lon).toFixed(4);
}
this.router.transitionTo('search', { queryParams });
}
<template>
<div class="category-chips-scroll">
<div class="category-chips-container">
{{#each this.categories as |category|}}
<button
type="button"
class="category-chip"
{{on "click" (fn this.searchCategory category)}}
aria-label={{category.label}}
disabled={{and
(eq this.mapUi.loadingState.type "category")
(eq this.mapUi.loadingState.value category.id)
}}
>
<Icon @name={{category.icon}} @size={{16}} />
<span>{{category.label}}</span>
</button>
{{/each}}
</div>
</div>
</template>
}

View File

@@ -1,10 +1,65 @@
import Component from '@glimmer/component'; import Component from '@glimmer/component';
import { htmlSafe } from '@ember/template'; import { htmlSafe } from '@ember/template';
import { getIcon, isIconFilled } from '../utils/icons';
import arrowLeft from 'feather-icons/dist/icons/arrow-left.svg?raw';
import activity from 'feather-icons/dist/icons/activity.svg?raw';
import bookmark from 'feather-icons/dist/icons/bookmark.svg?raw';
import clock from 'feather-icons/dist/icons/clock.svg?raw';
import edit from 'feather-icons/dist/icons/edit.svg?raw';
import facebook from 'feather-icons/dist/icons/facebook.svg?raw';
import globe from 'feather-icons/dist/icons/globe.svg?raw';
import home from 'feather-icons/dist/icons/home.svg?raw';
import instagram from 'feather-icons/dist/icons/instagram.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 mail from 'feather-icons/dist/icons/mail.svg?raw';
import map from 'feather-icons/dist/icons/map.svg?raw';
import mapPin from 'feather-icons/dist/icons/map-pin.svg?raw';
import menu from 'feather-icons/dist/icons/menu.svg?raw';
import navigation from 'feather-icons/dist/icons/navigation.svg?raw';
import phone from 'feather-icons/dist/icons/phone.svg?raw';
import plus from 'feather-icons/dist/icons/plus.svg?raw';
import server from 'feather-icons/dist/icons/server.svg?raw';
import search from 'feather-icons/dist/icons/search.svg?raw';
import settings from 'feather-icons/dist/icons/settings.svg?raw';
import target from 'feather-icons/dist/icons/target.svg?raw';
import user from 'feather-icons/dist/icons/user.svg?raw';
import x from 'feather-icons/dist/icons/x.svg?raw';
import zap from 'feather-icons/dist/icons/zap.svg?raw';
import wikipedia from '../icons/wikipedia.svg?raw';
const ICONS = {
'arrow-left': arrowLeft,
activity,
bookmark,
clock,
edit,
facebook,
globe,
home,
instagram,
'log-in': logIn,
'log-out': logOut,
mail,
map,
'map-pin': mapPin,
menu,
navigation,
phone,
plus,
server,
search,
settings,
target,
user,
wikipedia,
x,
zap,
};
export default class IconComponent extends Component { export default class IconComponent extends Component {
get svg() { get svg() {
return getIcon(this.args.name); return ICONS[this.args.name];
} }
get size() { get size() {
@@ -25,14 +80,10 @@ 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 this.isFilled 'icon-filled'}}" class="icon {{if @filled 'icon-filled'}}"
style={{this.style}} style={{this.style}}
title={{this.title}} title={{this.title}}
> >

View File

@@ -2,7 +2,7 @@ import Component from '@glimmer/component';
import { service } from '@ember/service'; import { service } from '@ember/service';
import { modifier } from 'ember-modifier'; import { modifier } from 'ember-modifier';
import 'ol/ol.css'; import 'ol/ol.css';
import OlMap from 'ol/Map.js'; import Map from 'ol/Map.js';
import { defaults as defaultControls, Control } from 'ol/control.js'; import { defaults as defaultControls, Control } from 'ol/control.js';
import { defaults as defaultInteractions, DragPan } from 'ol/interaction.js'; import { defaults as defaultInteractions, DragPan } from 'ol/interaction.js';
import Kinetic from 'ol/Kinetic.js'; import Kinetic from 'ol/Kinetic.js';
@@ -16,10 +16,8 @@ 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, Icon } from 'ol/style.js'; import { Style, Circle, Fill, Stroke } from 'ol/style.js';
import { apply } from 'ol-mapbox-style'; import { apply } from 'ol-mapbox-style';
import { getIcon } from '../utils/icons';
import { getIconNameForTags } from '../utils/osm-icons';
export default class MapComponent extends Component { export default class MapComponent extends Component {
@service osm; @service osm;
@@ -30,7 +28,6 @@ export default class MapComponent extends Component {
mapInstance; mapInstance;
bookmarkSource; bookmarkSource;
searchResultsSource;
selectedShapeSource; selectedShapeSource;
searchOverlay; searchOverlay;
searchOverlayElement; searchOverlayElement;
@@ -63,30 +60,9 @@ export default class MapComponent extends Component {
// Create a vector source and layer for bookmarks // Create a vector source and layer for bookmarks
this.bookmarkSource = new VectorSource(); this.bookmarkSource = new VectorSource();
const bookmarkLayer = new VectorLayer({
const bookmarkStyleFunction = (feature) => { source: this.bookmarkSource,
const originalPlace = feature.get('originalPlace'); style: [
let color =
getComputedStyle(document.documentElement)
.getPropertyValue('--default-list-color')
.trim() || '#000000'; // Fallback to black if variable is missing to make error obvious
if (
originalPlace &&
originalPlace._listIds &&
originalPlace._listIds.length > 0
) {
// Find the first list color
// We need access to storage.lists.
// Since this is inside setupMap, 'this' refers to the component instance.
const firstListId = originalPlace._listIds[0];
const list = this.storage.lists.find((l) => l.id === firstListId);
if (list && list.color) {
color = list.color;
}
}
return [
new Style({ new Style({
image: new Circle({ image: new Circle({
radius: 10, radius: 10,
@@ -97,117 +73,17 @@ export default class MapComponent extends Component {
new Style({ new Style({
image: new Circle({ image: new Circle({
radius: 9, radius: 9,
fill: new Fill({ color: color }), fill: new Fill({ color: '#ffcc33' }), // Gold/Yellow
stroke: new Stroke({ stroke: new Stroke({
color: '#fff', color: '#fff',
width: 2, width: 2,
}), }),
}), }),
}), }),
]; ],
};
const bookmarkLayer = new VectorLayer({
source: this.bookmarkSource,
style: bookmarkStyleFunction,
zIndex: 10, // Ensure it sits above the map tiles zIndex: 10, // Ensure it sits above the map tiles
}); });
// Create a vector source and layer for search results
this.searchResultsSource = new VectorSource();
const cachedIconUrls = new Map();
const searchResultStyle = (feature) => {
const originalPlace = feature.get('originalPlace');
// Some search results might be just the place object without separate tags
// If it's a raw place object, it might have osmTags property.
// Or it might be the tags object itself.
const tags = originalPlace.osmTags || originalPlace;
const iconName = getIconNameForTags(tags);
// Use 'default' key for the standard red dot marker. Use iconName as key if present.
const cacheKey = iconName || 'default';
if (!cachedIconUrls.has(cacheKey)) {
const markerColor =
getComputedStyle(document.documentElement)
.getPropertyValue('--marker-color-primary')
.trim() || '#ea4335';
// Default content: Red circle
let innerContent = `<circle cx="12" cy="12" r="8" fill="${markerColor}"/>`;
if (iconName) {
const rawSvg = getIcon(iconName);
if (rawSvg) {
// Pinhead icons are usually 15x15 viewBox="0 0 15 15".
// We want to center it on 12,12.
// A 12x12 icon centered at 12,12 means top-left at 6,6.
// However, since we are embedding a new SVG, we can just use x/y/width/height.
// But we need to strip the outer <svg> tag to embed the paths cleanly if we want full control,
// or we can nest the SVG. Nesting is safer.
// The rawSvg string contains <svg ...>...</svg>.
// We want to make it white. We can add a group with fill="white".
// But if the SVG has fill attributes, they override. Pinhead icons usually don't have fills.
// Let's strip the outer SVG tag to get the path content.
let content = rawSvg.trim();
const svgStart = content.indexOf('<svg');
const svgEnd = content.indexOf('>', svgStart);
const contentStart = svgEnd + 1;
const contentEnd = content.lastIndexOf('</svg>');
if (svgStart !== -1 && contentEnd !== -1) {
content = content.substring(contentStart, contentEnd);
}
// We render the red circle background, then the icon on top.
// Icon is scaled down slightly to fit nicely inside the circle.
// 15x15 scaled by 0.8 is 12x12.
// Translate to 6,6 to center.
innerContent = `
<circle cx="12" cy="12" r="8" fill="${markerColor}"/>
<g transform="translate(6, 6) scale(0.8)" fill="white">
${content}
</g>
`;
}
}
const svg = `
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-4 -4 32 40" width="40" height="50">
<defs>
<filter id="shadow" x="-50%" y="-50%" width="200%" height="200%">
<feDropShadow dx="0" dy="2" stdDeviation="1.5" flood-color="black" flood-opacity="0.3"/>
</filter>
</defs>
<path d="M12 2C6.5 2 2 6.5 2 12C2 17.5 12 24 12 24C12 24 22 17.5 22 12C22 6.5 17.5 2 12 2Z" fill="white" filter="url(#shadow)"/>
${innerContent}
</svg>
`;
cachedIconUrls.set(
cacheKey,
'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(svg.trim())
);
}
return new Style({
image: new Icon({
src: cachedIconUrls.get(cacheKey),
anchor: [0.5, 0.65],
scale: 1,
}),
});
};
const searchResultLayer = new VectorLayer({
source: this.searchResultsSource,
style: searchResultStyle,
zIndex: 11, // Above bookmarks (10)
});
// Default view settings // Default view settings
let center = [14.21683569, 27.060114248]; let center = [14.21683569, 27.060114248];
let zoom = 2.661; let zoom = 2.661;
@@ -239,14 +115,9 @@ export default class MapComponent extends Component {
projection: 'EPSG:3857', projection: 'EPSG:3857',
}); });
this.mapInstance = new OlMap({ this.mapInstance = new Map({
target: element, target: element,
layers: [ layers: [openfreemap, selectedShapeLayer, bookmarkLayer],
openfreemap,
selectedShapeLayer,
searchResultLayer,
bookmarkLayer,
],
view: view, view: view,
controls: defaultControls({ controls: defaultControls({
zoom: true, zoom: true,
@@ -281,7 +152,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: var(--marker-color-dark); 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: #b31412; stroke: none;"></circle></svg>`;
const pinShadow = document.createElement('div'); const pinShadow = document.createElement('div');
pinShadow.className = 'selected-pin-shadow'; pinShadow.className = 'selected-pin-shadow';
@@ -567,37 +438,9 @@ export default class MapComponent extends Component {
); );
}); });
updateSearchResults = modifier(() => {
if (!this.searchResultsSource) return;
this.searchResultsSource.clear();
const results = this.mapUi.searchResults;
if (!results || results.length === 0) return;
const features = [];
results.forEach((place) => {
if (place.lat && place.lon) {
const feature = new Feature({
geometry: new Point(fromLonLat([place.lon, place.lat])),
name: place.title,
id: place.id,
isSearchResult: true,
originalPlace: place,
});
features.push(feature);
}
});
if (features.length > 0) {
this.searchResultsSource.addFeatures(features);
}
});
// Track the selected place from the UI Service (Router -> Map) // Track the selected place from the UI Service (Router -> Map)
updateSelectedPin = modifier(() => { updateSelectedPin = modifier(() => {
const selected = this.mapUi.selectedPlace; const selected = this.mapUi.selectedPlace;
const options = this.mapUi.selectionOptions || {};
if (!this.selectedPinOverlay || !this.selectedPinElement) return; if (!this.selectedPinOverlay || !this.selectedPinElement) return;
@@ -628,11 +471,7 @@ export default class MapComponent extends Component {
} }
} }
if (options.preventZoom) { if (selected.bbox) {
// If we are preventing zoom (e.g. user clicked a bookmark), we rely on visibility check.
// This avoids unnecessary panning if the place is already visible.
this.handlePinVisibility(coords, { maintainZoom: true });
} else if (selected.bbox) {
this.zoomToBbox(selected.bbox); this.zoomToBbox(selected.bbox);
} else { } else {
this.handlePinVisibility(coords); this.handlePinVisibility(coords);
@@ -676,10 +515,7 @@ 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 sidebarWidthVar = getComputedStyle(document.documentElement) const sidebarWidth = 400;
.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
@@ -694,23 +530,13 @@ export default class MapComponent extends Component {
padding: padding, padding: padding,
duration: 1000, duration: 1000,
easing: (t) => t * (2 - t), easing: (t) => t * (2 - t),
maxZoom: Math.max(currentZoom, 18), maxZoom: currentZoom,
}); });
} }
handlePinVisibility(coords, options = {}) { handlePinVisibility(coords) {
if (!this.mapInstance) return; if (!this.mapInstance) return;
const view = this.mapInstance.getView();
const currentZoom = view.getZoom();
// If too far out (e.g. world view), zoom in to neighborhood level (16)
// UNLESS we want to maintain the current zoom
if (!options.maintainZoom && currentZoom < 16) {
this.animateToSmartCenter(coords, 16);
return;
}
const pixel = this.mapInstance.getPixelFromCoordinate(coords); const pixel = this.mapInstance.getPixelFromCoordinate(coords);
const size = this.mapInstance.getSize(); const size = this.mapInstance.getSize();
@@ -723,27 +549,18 @@ export default class MapComponent extends Component {
pixel[1] > size[1]; pixel[1] > size[1];
if (isOffScreen) { if (isOffScreen) {
// If off-screen, center it smartly (considering sidebar/bottom sheet) this.animateToSmartCenter(coords);
// 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);
} }
} }
animateToSmartCenter(coords, zoom = null) { animateToSmartCenter(coords) {
if (!this.mapInstance) return; if (!this.mapInstance) return;
const size = this.mapInstance.getSize(); const size = this.mapInstance.getSize();
const view = this.mapInstance.getView(); const view = this.mapInstance.getView();
let resolution = view.getResolution(); const resolution = view.getResolution();
if (zoom !== null) {
resolution = view.getResolutionForZoom(zoom);
}
let targetCenter = coords; let targetCenter = coords;
// Check if mobile (width <= 768px matches CSS) // Check if mobile (width <= 768px matches CSS)
@@ -764,113 +581,45 @@ 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. view.animate({
// 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 = {
center: targetCenter, center: targetCenter,
duration: 1000, duration: 1000,
easing: (t) => t * (2 - t), // Ease-out easing: (t) => t * (2 - t), // Ease-out
}; });
if (zoom !== null) {
animationOptions.zoom = zoom;
}
view.animate(animationOptions);
} }
panIfObscured(coords) { panIfObscured(coords) {
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();
// Default targets (current position) // Move the map center SOUTH (decrease Y) to move the pin UP (decrease pixel Y)
let targetPixelX = pixel[0]; const deltaMapUnits = deltaY * resolution;
let targetPixelY = pixel[1]; const newCenter = [center[0], center[1] - deltaMapUnits];
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: [newCenterX, newCenterY], center: newCenter,
duration: 500, duration: 500,
easing: (t) => t * (2 - t), // Ease-out easing: (t) => t * (2 - t), // Ease-out
}); });
@@ -1044,7 +793,6 @@ 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);
@@ -1076,7 +824,6 @@ 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) {
@@ -1085,12 +832,8 @@ 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();
@@ -1099,30 +842,15 @@ export default class MapComponent extends Component {
} }
} }
// Helper to transition with proper state
const transitionToPlace = (place) => {
// If we are currently in search mode OR have active search results,
// we want the "Back" button on the place details to return to the search results.
if (
this.router.currentRouteName === 'search' ||
(this.mapUi.currentSearch && this.mapUi.searchResults.length > 0)
) {
this.mapUi.returnToSearch = true;
}
this.mapUi.preventNextZoom = true;
this.router.transitionTo('place', place);
};
// Special handling when sidebar is OPEN // Special handling when sidebar is OPEN
if (this.args.isSidebarOpen) { if (this.args.isSidebarOpen) {
// If it's a bookmark or search result, we allow "switching" to it even if sidebar is open // If it's a bookmark, we allow "switching" to it even if sidebar is open
const targetPlace = clickedBookmark || clickedSearchResult; if (clickedBookmark) {
if (targetPlace) {
console.debug( console.debug(
'Clicked feature while sidebar open (switching):', 'Clicked bookmark while sidebar open (switching):',
targetPlace clickedBookmark
); );
transitionToPlace(targetPlace); this.router.transitionTo('place', clickedBookmark);
return; return;
} }
@@ -1136,13 +864,7 @@ export default class MapComponent extends Component {
// Normal behavior (sidebar is closed) // Normal behavior (sidebar is closed)
if (clickedBookmark) { if (clickedBookmark) {
console.debug('Clicked bookmark:', clickedBookmark); console.debug('Clicked bookmark:', clickedBookmark);
transitionToPlace(clickedBookmark); this.router.transitionTo('place', clickedBookmark);
return;
}
if (clickedSearchResult) {
console.debug('Clicked search result:', clickedSearchResult);
transitionToPlace(clickedSearchResult);
return; return;
} }
@@ -1183,7 +905,6 @@ export default class MapComponent extends Component {
lat: lat.toFixed(6), lat: lat.toFixed(6),
lon: lon.toFixed(6), lon: lon.toFixed(6),
q: null, // Clear q to force spatial search q: null, // Clear q to force spatial search
category: null, // Clear category to force spatial search
selected: selectedFeatureName || null, selected: selectedFeatureName || null,
}; };
@@ -1196,7 +917,6 @@ 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}}

View File

@@ -1,39 +1,23 @@
import Component from '@glimmer/component'; import Component from '@glimmer/component';
import { service } from '@ember/service'; import { fn } from '@ember/helper';
import { on } from '@ember/modifier'; import { on } from '@ember/modifier';
import { htmlSafe } from '@ember/template'; import { htmlSafe } from '@ember/template';
import { humanizeOsmTag } from '../utils/format-text'; import { humanizeOsmTag } from '../utils/format-text';
import { getLocalizedName, getPlaceType } from '../utils/osm'; import { getLocalizedName, getPlaceType } from '../utils/osm';
import { mapToStorageSchema } from '../utils/place-mapping';
import { getSocialInfo } from '../utils/social-links'; import { getSocialInfo } from '../utils/social-links';
import Icon from '../components/icon'; import Icon from '../components/icon';
import PlaceEditForm from './place-edit-form'; import PlaceEditForm from './place-edit-form';
import PlaceListsManager from './place-lists-manager';
import { tracked } from '@glimmer/tracking'; import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object'; import { action } from '@ember/object';
export default class PlaceDetails extends Component { export default class PlaceDetails extends Component {
@service storage;
@tracked isEditing = false; @tracked isEditing = false;
@tracked showLists = false;
get isSaved() {
return this.storage.isPlaceSaved(this.place.id || this.place.osmId);
}
get place() { get place() {
return this.args.place || {}; return this.args.place || {};
} }
get saveablePlace() {
if (this.place.createdAt) {
return this.place;
}
return mapToStorageSchema(this.place);
}
get tags() { get tags() {
return this.place.osmTags || {}; return this.place.osmTags || {};
} }
@@ -44,7 +28,7 @@ export default class PlaceDetails extends Component {
@action @action
startEditing() { startEditing() {
if (!this.isSaved) return; // Only allow editing saved places if (!this.place.createdAt) return; // Only allow editing saved places
this.isEditing = true; this.isEditing = true;
} }
@@ -53,21 +37,6 @@ export default class PlaceDetails extends Component {
this.isEditing = false; this.isEditing = false;
} }
@action
toggleLists(event) {
// Prevent this click from propagating to the document listener
// which handles the "click outside" logic.
if (event) {
event.stopPropagation();
}
this.showLists = !this.showLists;
}
@action
closeLists() {
this.showLists = false;
}
@action @action
async saveChanges(changes) { async saveChanges(changes) {
if (this.args.onSave) { if (this.args.onSave) {
@@ -278,37 +247,30 @@ export default class PlaceDetails extends Component {
{{/if}} {{/if}}
<div class="actions"> <div class="actions">
<div class="save-button-wrapper">
<button <button
type="button" type="button"
class={{if this.isSaved "btn btn-secondary" "btn btn-outline"}} class={{if
{{on "click" this.toggleLists}} this.place.createdAt
"btn btn-secondary"
"btn btn-outline"
}}
{{on "click" (fn @onToggleSave this.place)}}
> >
<Icon <Icon
@name="bookmark" @name="bookmark"
@color={{if this.isSaved "currentColor" "var(--link-color)"}} @color={{if this.place.createdAt "currentColor" "#007bff"}}
/> />
{{if this.isSaved "Saved" "Save"}} {{if this.place.createdAt "Saved" "Save"}}
</button> </button>
{{#if this.showLists}} {{#if this.place.createdAt}}
<PlaceListsManager
@place={{this.saveablePlace}}
@onClose={{this.closeLists}}
@isSaved={{this.isSaved}}
/>
{{/if}}
</div>
{{#if this.isSaved}}
<button <button
type="button" type="button"
class="btn btn-outline" class="btn btn-outline"
title="Edit" title="Edit"
disabled={{this.isEditing}}
{{on "click" this.startEditing}} {{on "click" this.startEditing}}
> >
<Icon @name="edit" @color="var(--link-color)" /> <Icon @name="edit" @color="#007bff" />
Edit Edit
</button> </button>
{{/if}} {{/if}}
@@ -317,11 +279,9 @@ export default class PlaceDetails extends Component {
<div class="meta-info"> <div class="meta-info">
{{#if this.cuisine}} {{#if this.cuisine}}
<p class="content-with-icon"> <p class="cuisine-info">
<Icon @name="fork-and-knife" @title="Cuisine" /> <strong>Cuisine:</strong>
<span>
{{this.cuisine}} {{this.cuisine}}
</span>
</p> </p>
{{/if}} {{/if}}
@@ -393,7 +353,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" /> <Icon @name="wikipedia" @title="Wikipedia" @filled={{true}} />
<span> <span>
<a <a
href="https://wikipedia.org/wiki/{{this.wikipedia}}" href="https://wikipedia.org/wiki/{{this.wikipedia}}"

View File

@@ -1,135 +0,0 @@
import Component from '@glimmer/component';
import { service } from '@ember/service';
import { action } from '@ember/object';
import { tracked } from '@glimmer/tracking';
import { on } from '@ember/modifier';
import { fn } from '@ember/helper';
import { htmlSafe } from '@ember/template';
import onClickOutside from '../modifiers/on-click-outside';
export default class PlaceListsManager extends Component {
@service storage;
@service router;
@tracked _forceClear = false;
get isSaved() {
return this.args.isSaved;
}
get placeListIds() {
if (this._forceClear) return [];
return this.args.place._listIds || [];
}
styleFor(color) {
return htmlSafe(`background-color: ${color}`);
}
@action
isInList(list) {
if (!this.placeListIds) return false;
return this.placeListIds.includes(list.id);
}
@action
async toggleSaved() {
if (this.isSaved) {
const { osmId, osmType } = this.args.place;
await this.storage.removePlace(this.args.place);
// Clean up the local object reference immediately to prevent UI flicker
// or stale state if the transition is delayed/cancelled.
if (this.args.place) {
this.args.place.id = null;
this.args.place.createdAt = null;
this.args.place._listIds = [];
this._forceClear = true;
}
// Transition immediately to the canonical state
if (osmId && osmType) {
// Create a transient copy that looks like a fresh OSM result
const rawPlace = { ...this.args.place };
delete rawPlace.id;
delete rawPlace.createdAt;
delete rawPlace._listIds;
// Transition to the place route using the raw object
// This updates the URL to 'osm:...' and renders immediately
this.router.transitionTo('place', rawPlace);
} else {
// Custom place deleted -> go home
this.router.transitionTo('index');
}
if (this.args.onClose) this.args.onClose();
} else {
await this.storage.storePlace(this.args.place);
}
}
@action
async toggleList(list) {
const isMember = this.placeListIds.includes(list.id);
const shouldAdd = !isMember;
if (shouldAdd && !this.isSaved) {
// Auto-save if adding to list
await this.storage.storePlace(this.args.place);
}
try {
// Toggle membership
// We must pass the SAVED place (with ID) to the toggle function
// If we just saved it above, the args.place might still be the old object reference unless storage updates it in-place?
// StorageService.storePlace returns the new object.
// But togglePlaceList handles saving internally if ID is missing.
// Let's rely on storage.togglePlaceList to handle the "save if needed" part.
await this.storage.togglePlaceList(this.args.place, list.id, shouldAdd);
} catch (e) {
console.error(e);
alert('Failed to update list: ' + e.message);
}
}
<template>
<div class="place-lists-manager" {{onClickOutside @onClose}}>
<div class="list-item master-toggle">
<label>
<input
type="checkbox"
checked={{this.isSaved}}
{{on "change" this.toggleSaved}}
/>
<span class="list-color"></span>
<span class="list-name">Saved places</span>
</label>
</div>
<div class="divider"></div>
<div class="lists-container">
{{#each this.storage.lists as |list|}}
<div class="list-item">
<label>
<input
type="checkbox"
checked={{this.isInList list}}
{{on "change" (fn this.toggleList list)}}
disabled={{unless this.isSaved true}}
/>
{{! template-lint-disable no-inline-styles }}
<span
class="list-color"
style={{this.styleFor list.color}}
></span>
<span class="list-name">{{list.title}}</span>
</label>
</div>
{{/each}}
</div>
</div>
</template>
}

View File

@@ -51,7 +51,7 @@ export default class PlacesSidebar extends Component {
if (!place) return; if (!place) return;
if (place.createdAt) { if (place.createdAt) {
// Direct delete without confirmation if (confirm(`Delete "${place.title}"?`)) {
try { try {
await this.storage.removePlace(place); await this.storage.removePlace(place);
console.debug('Place deleted:', place.title); console.debug('Place deleted:', place.title);
@@ -85,6 +85,7 @@ export default class PlacesSidebar extends Component {
console.error('Failed to delete:', e); console.error('Failed to delete:', e);
alert('Failed to delete: ' + e.message); alert('Failed to delete: ' + e.message);
} }
}
} else { } else {
// It's a fresh POI -> Save it // It's a fresh POI -> Save it
const placeData = { const placeData = {
@@ -146,7 +147,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.category && qp.lat && qp.lon; return !qp.q && qp.lat && qp.lon;
} }
<template> <template>
@@ -226,7 +227,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="var(--link-color)" /> <Icon @name="plus" @size={{18}} @color="#007bff" />
Create new place Create new place
</button> </button>
{{/if}} {{/if}}

View File

@@ -7,55 +7,38 @@ 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, or } from 'ember-truth-helpers';
export default class SearchBoxComponent extends Component { export default class SearchBoxComponent extends Component {
@service photon; @service photon;
@service osm;
@service router; @service router;
@service mapUi; @service mapUi;
@service map; // Assuming we might need map context, but mostly we use router @service map; // Assuming we might need map context, but mostly we use router
@tracked _internalQuery = ''; @tracked query = '';
@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) {
const value = event.target.value; this.query = event.target.value;
this.query = value; if (this.query.length < 2) {
if (this.args.onQueryChange) {
this.args.onQueryChange(value);
}
if (value.length < 2) {
this.results = []; this.results = [];
return; return;
} }
this.searchTask.perform(value); this.searchTask.perform();
} }
searchTask = task({ restartable: true }, async (term) => { searchTask = task({ restartable: true }, async () => {
await timeout(300); await timeout(300);
const query = typeof term === 'string' ? term : this.query; if (this.query.length < 2) return;
if (query.length < 2) return;
this.isLoading = true; this.isLoading = true;
try { try {
@@ -64,20 +47,8 @@ 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);
// Filter categories this.results = results;
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 = [];
@@ -109,7 +80,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, category: null }; let queryParams = { q: this.query, selected: null };
if (this.mapUi.currentCenter) { if (this.mapUi.currentCenter) {
const { lat, lon } = this.mapUi.currentCenter; const { lat, lon } = this.mapUi.currentCenter;
@@ -123,37 +94,7 @@ 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
@@ -171,7 +112,6 @@ export default class SearchBoxComponent extends Component {
lat: place.lat, lat: place.lat,
lon: place.lon, lon: place.lon,
selected: null, selected: null,
category: null,
}, },
}); });
} }
@@ -179,17 +119,10 @@ export default class SearchBoxComponent extends Component {
@action @action
clear() { clear() {
this.searchTask.cancelAll();
this.mapUi.stopLoading();
this.osm.cancelAll();
this.photon.cancelAll();
this.query = ''; this.query = '';
this.results = []; this.results = [];
if (this.args.onQueryChange) { this.router.transitionTo('index'); // Or stay on current page?
this.args.onQueryChange(''); // Usually clear just clears the input.
}
this.router.transitionTo('index');
} }
<template> <template>
@@ -217,16 +150,7 @@ export default class SearchBoxComponent extends Component {
/> />
<button type="submit" class="search-submit-btn" aria-label="Search"> <button type="submit" class="search-submit-btn" aria-label="Search">
{{#if
(or
(eq this.mapUi.loadingState.type "text")
(eq this.mapUi.loadingState.type "category")
)
}}
<Icon @name="loading-ring" @size={{20}} />
{{else}}
<Icon @name="search" @size={{20}} @color="#5f6368" /> <Icon @name="search" @size={{20}} @color="#5f6368" />
{{/if}}
</button> </button>
{{#if this.query}} {{#if this.query}}
@@ -252,11 +176,7 @@ 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 <Icon @name="map-pin" @size={{16}} @color="#666" />
@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>

View File

@@ -0,0 +1,128 @@
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>
}

View File

@@ -1,14 +0,0 @@
import Component from '@glimmer/component';
import { service } from '@ember/service';
export default class ToastComponent extends Component {
@service toast;
<template>
{{#if this.toast.isVisible}}
<div class="toast-notification">
{{this.toast.message}}
</div>
{{/if}}
</template>
}

View File

@@ -1,11 +1,10 @@
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', 'category']; queryParams = ['lat', 'lon', 'q', 'selected'];
lat = null; lat = null;
lon = null; lon = null;
q = null; q = null;
selected = null; selected = null;
category = null;
} }

View File

@@ -1 +0,0 @@
<svg width="24" height="24" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" data-ember-extension="1"><path d="M10.72,19.9a8,8,0,0,1-6.5-9.79A7.77,7.77,0,0,1,10.4,4.16a8,8,0,0,1,9.49,6.52A1.54,1.54,0,0,0,21.38,12h.13a1.37,1.37,0,0,0,1.38-1.54,11,11,0,1,0-12.7,12.39A1.54,1.54,0,0,0,12,21.34h0A1.47,1.47,0,0,0,10.72,19.9Z"><animateTransform attributeName="transform" type="rotate" dur="0.75s" values="0 12 12;360 12 12" repeatCount="indefinite"/></path></svg>

Before

Width:  |  Height:  |  Size: 464 B

View File

@@ -1,45 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -1,21 +0,0 @@
import { modifier } from 'ember-modifier';
export default modifier((element, [callback]) => {
const handler = (event) => {
// Check if the click target is contained within the element
if (element && !element.contains(event.target)) {
callback(event);
}
};
// Delay attaching the listener to avoid catching the opening click
// (using a microtask or setTimeout 0)
const timer = setTimeout(() => {
document.addEventListener('click', handler);
}, 0);
return () => {
clearTimeout(timer);
document.removeEventListener('click', handler);
};
});

View File

@@ -1,10 +0,0 @@
import Route from '@ember/routing/route';
import { service } from '@ember/service';
export default class IndexRoute extends Route {
@service mapUi;
activate() {
this.mapUi.clearSearchResults();
}
}

View File

@@ -72,9 +72,7 @@ export default class PlaceRoute extends Route {
// Notify the Map UI to show the pin // Notify the Map UI to show the pin
if (model) { if (model) {
const options = { preventZoom: this.mapUi.preventNextZoom }; this.mapUi.selectPlace(model);
this.mapUi.selectPlace(model, options);
this.mapUi.preventNextZoom = false;
} }
// Stop the pulse animation if it was running (e.g. redirected from search) // Stop the pulse animation if it was running (e.g. redirected from search)
this.mapUi.stopSearch(); this.mapUi.stopSearch();
@@ -101,23 +99,6 @@ 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) {

View File

@@ -9,68 +9,21 @@ export default class SearchRoute extends Route {
@service mapUi; @service mapUi;
@service storage; @service storage;
@service router; @service router;
@service toast;
queryParams = { queryParams = {
lat: { refreshModel: true }, lat: { refreshModel: true },
lon: { refreshModel: true }, lon: { refreshModel: true },
q: { refreshModel: true }, q: { refreshModel: true },
selected: { refreshModel: true }, selected: { refreshModel: true },
category: { refreshModel: true },
}; };
async model(params) { async model(params) {
const lat = params.lat ? parseFloat(params.lat) : null; const lat = params.lat ? parseFloat(params.lat) : null;
const lon = params.lon ? parseFloat(params.lon) : null; const lon = params.lon ? parseFloat(params.lon) : null;
let pois = []; let pois = [];
let loadingType = null;
let loadingValue = null;
try {
// Case 0: Category Search (category parameter present)
if (params.category && lat && lon) {
loadingType = 'category';
loadingValue = params.category;
this.mapUi.startLoading(loadingType, loadingValue);
// We need bounds. If we have active map state, use it.
let bounds = this.mapUi.currentBounds;
// If we don't have bounds (direct URL visit), estimate them from lat/lon/zoom(16)
// or just use a fixed box around the center.
if (!bounds) {
// Approximate 0.01 degrees ~ 1km at equator. A viewport is roughly 0.02x0.01 at zoom 16?
// Let's take a safe box of ~1km radius.
const delta = 0.01;
bounds = {
minLat: lat - delta,
maxLat: lat + delta,
minLon: lon - delta,
maxLon: lon + delta,
};
}
pois = await this.osm.getCategoryPois(
bounds,
params.category,
lat,
lon
);
// Sort by distance from center
pois = pois
.map((p) => ({
...p,
_distance: getDistance(lat, lon, p.lat, p.lon),
}))
.sort((a, b) => a._distance - b._distance);
}
// Case 1: Text Search (q parameter present) // Case 1: Text Search (q parameter present)
else if (params.q) { if (params.q) {
loadingType = 'text';
loadingValue = params.q;
this.mapUi.startLoading(loadingType, loadingValue);
// Search with Photon (using lat/lon for bias if available) // Search with Photon (using lat/lon for bias if available)
pois = await this.photon.search(params.q, lat, lon); pois = await this.photon.search(params.q, lat, lon);
@@ -97,7 +50,6 @@ export default class SearchRoute extends Route {
} }
// Case 2: Nearby Search (lat/lon present, no q) // Case 2: Nearby Search (lat/lon present, no q)
else if (lat && lon) { else if (lat && lon) {
// Nearby search does NOT trigger loading state (pulse is used instead)
const searchRadius = 50; // Default radius const searchRadius = 50; // Default radius
// Fetch POIs from Overpass // Fetch POIs from Overpass
@@ -132,11 +84,6 @@ export default class SearchRoute extends Route {
}) })
.sort((a, b) => a._distance - b._distance); .sort((a, b) => a._distance - b._distance);
} }
} finally {
if (loadingType && loadingValue) {
this.mapUi.stopLoading(loadingType, loadingValue);
}
}
// Check if any of these are already bookmarked // Check if any of these are already bookmarked
// We resolve them to the bookmark version if they exist // We resolve them to the bookmark version if they exist
@@ -192,20 +139,11 @@ export default class SearchRoute extends Route {
super.setupController(controller, model); super.setupController(controller, model);
// Ensure pulse is stopped if we reach here // Ensure pulse is stopped if we reach here
this.mapUi.stopSearch(); this.mapUi.stopSearch();
this.mapUi.setSearchResults(model);
// Store current search params to allow "Up" navigation from place details
const { q, category, lat, lon } = this.paramsFor('search');
this.mapUi.currentSearch = { q, category, lat, lon };
} }
@action @action
error(error, transition) { error() {
this.mapUi.stopSearch(); this.mapUi.stopSearch();
this.toast.show('Search request failed. Please try again.'); return true; // Bubble error
if (transition) {
transition.abort();
}
return false; // Prevent bubble and stop transition
} }
} }

View File

@@ -8,38 +8,19 @@ 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 preventNextZoom = false;
@tracked searchResults = [];
@tracked currentSearch = null;
@tracked loadingState = null;
selectPlace(place, options = {}) { selectPlace(place) {
this.selectedPlace = place; this.selectedPlace = place;
this.selectionOptions = options;
} }
clearSelection() { clearSelection() {
this.selectedPlace = null; this.selectedPlace = null;
this.selectionOptions = {};
this.preventNextZoom = false;
}
setSearchResults(results) {
this.searchResults = results || [];
}
clearSearchResults() {
this.searchResults = [];
this.currentSearch = null;
} }
startSearch() { startSearch() {
this.isSearching = true; this.isSearching = true;
this.isCreating = false; this.isCreating = false;
this.preventNextZoom = false;
} }
stopSearch() { stopSearch() {
@@ -67,30 +48,4 @@ export default class MapUiService extends Service {
updateCenter(lat, lon) { updateCenter(lat, lon) {
this.currentCenter = { lat, lon }; this.currentCenter = { lat, lon };
} }
updateBounds(bounds) {
this.currentBounds = bounds;
}
startLoading(type, value) {
this.loadingState = { type, value };
}
stopLoading(type = null, value = null) {
// If no arguments provided, force stop (legacy/cleanup)
if (!type && !value) {
this.loadingState = null;
return;
}
// Only clear if the current state matches the request
// This prevents a previous search from clearing the state of a new search
if (
this.loadingState &&
this.loadingState.type === type &&
this.loadingState.value === value
) {
this.loadingState = null;
}
}
} }

View File

@@ -1,6 +1,5 @@
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;
@@ -9,13 +8,6 @@ export default class OsmService extends Service {
cachedResults = null; cachedResults = null;
lastQueryKey = null; lastQueryKey = null;
cancelAll() {
if (this.controller) {
this.controller.abort();
this.controller = null;
}
}
async getNearbyPois(lat, lon, radius = 50) { async getNearbyPois(lat, lon, radius = 50) {
const queryKey = `${lat},${lon},${radius}`; const queryKey = `${lat},${lon},${radius}`;
@@ -47,26 +39,15 @@ export default class OsmService extends Service {
]; ];
const typeKeysQuery = [`~"^(${typeKeys.join('|')})$"~".*"`]; const typeKeysQuery = [`~"^(${typeKeys.join('|')})$"~".*"`];
const negativeFilters = {
public_transport: ['stop_area', 'platform'],
};
const negativeFiltersQuery = Object.entries(negativeFilters)
.map(([key, values]) => {
const valueRegex = `^(${values.join('|')})$`;
return `["${key}"!~"${valueRegex}"]`;
})
.join('');
const query = ` const query = `
[out:json][timeout:25]; [out:json][timeout:25];
( (
node(around:${radius},${lat},${lon}) node(around:${radius},${lat},${lon})
[${typeKeysQuery}]${negativeFiltersQuery}[~"^name"~"."]; [${typeKeysQuery}][~"^name"~"."];
way(around:${radius},${lat},${lon}) way(around:${radius},${lat},${lon})
[${typeKeysQuery}]${negativeFiltersQuery}[~"^name"~"."]; [${typeKeysQuery}][~"^name"~"."];
relation(around:${radius},${lat},${lon}) relation(around:${radius},${lat},${lon})
[${typeKeysQuery}]${negativeFiltersQuery}[~"^name"~"."]; [${typeKeysQuery}][~"^name"~"."];
); );
out center; out center;
`.trim(); `.trim();
@@ -95,70 +76,6 @@ out center;
} }
} }
async getCategoryPois(bounds, categoryId, lat, lon) {
const category = getCategoryById(categoryId);
if (!category || !bounds) return [];
const queryKey = lat && lon ? `cat:${categoryId}:${lat}:${lon}` : null;
if (queryKey && this.lastQueryKey === queryKey && this.cachedResults) {
console.debug('Returning cached category results for:', queryKey);
return this.cachedResults;
}
if (this.controller) {
this.controller.abort();
}
this.controller = new AbortController();
const signal = this.controller.signal;
const { minLat, minLon, maxLat, maxLon } = bounds;
// Build the query parts for each filter string and type
const queryParts = [];
// Default types if not specified (legacy fallback)
const types = category.types || ['node', 'way', 'relation'];
category.filter.forEach((filterString) => {
types.forEach((type) => {
// We ensure we only fetch named POIs to reduce noise
queryParts.push(`${type}${filterString}[~"^name"~"."];`);
});
});
const query = `
[out:json][timeout:25][bbox:${minLat},${minLon},${maxLat},${maxLon}];
(
${queryParts.join('\n ')}
);
out center;
`.trim();
const url = `${this.settings.overpassApi}?data=${encodeURIComponent(query)}`;
try {
const res = await this.fetchWithRetry(url, { signal });
if (!res.ok) throw new Error('Overpass request failed');
const data = await res.json();
const results = data.elements.map(this.normalizePoi);
if (queryKey) {
this.lastQueryKey = queryKey;
this.cachedResults = results;
}
return results;
} catch (e) {
if (e.name === 'AbortError') {
console.debug('Category search aborted');
return [];
}
console.error('Category search failed', e);
throw e;
}
}
normalizePoi(poi) { normalizePoi(poi) {
const tags = poi.tags || {}; const tags = poi.tags || {};
const type = getPlaceType(tags) || 'Point of Interest'; const type = getPlaceType(tags) || 'Point of Interest';

View File

@@ -1,32 +1,13 @@
import Service, { service } from '@ember/service'; import 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 {
@service settings; baseUrl = 'https://photon.komoot.io/api/';
controller = null;
cancelAll() {
if (this.controller) {
this.controller.abort();
this.controller = null;
}
}
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 [];
if (this.controller) {
this.controller.abort();
}
this.controller = new AbortController();
const signal = this.controller.signal;
const params = new URLSearchParams({ const params = new URLSearchParams({
q: query, q: query,
limit: String(limit), limit: String(limit),
@@ -40,7 +21,7 @@ export default class PhotonService extends Service {
const url = `${this.baseUrl}?${params.toString()}`; const url = `${this.baseUrl}?${params.toString()}`;
try { try {
const res = await this.fetchWithRetry(url, { signal }); const res = await this.fetchWithRetry(url);
if (!res.ok) { if (!res.ok) {
throw new Error(`Photon request failed with status ${res.status}`); throw new Error(`Photon request failed with status ${res.status}`);
} }
@@ -50,9 +31,6 @@ export default class PhotonService extends Service {
return data.features.map((f) => this.normalizeFeature(f)); return data.features.map((f) => this.normalizeFeature(f));
} catch (e) { } catch (e) {
if (e.name === 'AbortError') {
return [];
}
console.error('Photon search error:', e); console.error('Photon search error:', e);
// Return empty array on error so UI doesn't break // Return empty array on error so UI doesn't break
return []; return [];

View File

@@ -4,8 +4,6 @@ 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/';
@tracked showQuickSearchButtons = true;
overpassApis = [ overpassApis = [
{ {
@@ -26,13 +24,6 @@ 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();
@@ -57,13 +48,6 @@ export default class SettingsService extends Service {
this.mapKinetic = savedKinetic === 'true'; this.mapKinetic = savedKinetic === 'true';
} }
// Default is true (initialized in class field) // Default is true (initialized in class field)
const savedShowQuickSearch = localStorage.getItem(
'marco:show-quick-search'
);
if (savedShowQuickSearch !== null) {
this.showQuickSearchButtons = savedShowQuickSearch === 'true';
}
} }
updateOverpassApi(url) { updateOverpassApi(url) {
@@ -75,13 +59,4 @@ 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));
} }
updateShowQuickSearchButtons(enabled) {
this.showQuickSearchButtons = enabled;
localStorage.setItem('marco:show-quick-search', String(enabled));
}
updatePhotonApi(url) {
this.photonApi = url;
}
} }

View File

@@ -1,4 +1,4 @@
import Service, { service } from '@ember/service'; import 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,17 +7,14 @@ 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 = [];
@tracked savedPlaces = []; @tracked savedPlaces = [];
@tracked loadedPrefixes = []; @tracked loadedPrefixes = [];
@tracked currentBbox = null; @tracked currentBbox = null;
@tracked lists = [];
@tracked version = 0; // Shared version tracker for bookmarks @tracked version = 0; // Shared version tracker for bookmarks
@tracked initialSyncDone = false; @tracked initialSyncDone = false;
@tracked connected = false; @tracked connected = false;
@@ -49,11 +46,6 @@ export default class StorageService extends Service {
this.rs.on('connected', () => { this.rs.on('connected', () => {
this.connected = true; this.connected = true;
this.userAddress = this.rs.remote.userAddress; this.userAddress = this.rs.remote.userAddress;
this.loadLists();
});
this.rs.on('not-connected', () => {
this.loadLists();
}); });
this.rs.on('disconnected', () => { this.rs.on('disconnected', () => {
@@ -62,7 +54,6 @@ export default class StorageService extends Service {
this.placesInView = []; this.placesInView = [];
this.savedPlaces = []; this.savedPlaces = [];
this.loadedPrefixes = []; this.loadedPrefixes = [];
this.lists = [];
this.initialSyncDone = false; this.initialSyncDone = false;
}); });
@@ -70,18 +61,13 @@ export default class StorageService extends Service {
// console.debug('[rs] sync done:', result); // console.debug('[rs] sync done:', result);
if (!this.initialSyncDone) { if (!this.initialSyncDone) {
this.initialSyncDone = true; this.initialSyncDone = true;
this.loadLists();
} }
}); });
this.rs.scope('/places/').on('change', (event) => { this.rs.scope('/places/').on('change', (event) => {
// console.debug(event); // console.debug(event);
if (event.relativePath.startsWith('_lists/')) {
this.loadLists();
} else {
this.handlePlaceChange(event); this.handlePlaceChange(event);
debounceTask(this, 'reloadCurrentView', 200); debounceTask(this, 'reloadCurrentView', 200);
}
}); });
} }
@@ -134,98 +120,6 @@ export default class StorageService extends Service {
this.loadAllPlaces(required); this.loadAllPlaces(required);
} }
async loadLists() {
try {
if (!this.places.lists) return; // Wait for module init
// Ensure defaults exist first
await this.places.lists.initDefaults();
const lists = await this.places.lists.getAll();
this.lists = lists || [];
// Decorate with hardcoded icons for default lists (in-memory only)
this.lists.forEach((list) => {
if (list.id === 'to-go') {
list.icon = 'bookmark';
} else if (list.id === 'to-do') {
list.icon = 'check-square';
}
});
this.refreshPlaceListAssociations();
} catch (e) {
console.error('Failed to load lists:', e);
}
}
refreshPlaceListAssociations() {
// 1. Build an index of PlaceID -> ListID[]
const placeToListMap = new Map();
this.lists.forEach((list) => {
if (list.placeRefs && Array.isArray(list.placeRefs)) {
list.placeRefs.forEach((ref) => {
if (!ref.id) return;
if (!placeToListMap.has(ref.id)) {
placeToListMap.set(ref.id, []);
}
placeToListMap.get(ref.id).push(list.id);
});
}
});
// 2. Helper to attach lists to a place object
const attachLists = (place) => {
const listIds = placeToListMap.get(place.id) || [];
// Assign directly to object property (non-tracked mutation is fine as we trigger updates below)
place._listIds = listIds;
return place;
};
// 3. Update savedPlaces
this.savedPlaces = this.savedPlaces.map((p) => attachLists({ ...p }));
// 4. Update placesInView
this.placesInView = this.placesInView.map((p) => attachLists({ ...p }));
}
async togglePlaceList(place, listId, shouldBeInList) {
if (!place) return;
// Ensure place is saved first if it's new
let savedPlace = place;
if (!place.id || !place.geohash) {
if (shouldBeInList) {
// If adding to a list, we must save the place first
savedPlace = await this.storePlace(place);
} else {
return; // Can't remove an unsaved place from a list
}
}
try {
if (shouldBeInList) {
await this.places.lists.addPlace(
listId,
savedPlace.id,
savedPlace.geohash
);
} else {
await this.places.lists.removePlace(listId, savedPlace.id);
}
// Reload lists to reflect changes
await this.loadLists();
// Return the updated place
return this.findPlaceById(savedPlace.id);
} catch (e) {
console.error('Failed to toggle place in list:', e);
throw e;
}
}
async loadPlacesInBounds(bbox) { async loadPlacesInBounds(bbox) {
// 1. Calculate required prefixes // 1. Calculate required prefixes
const requiredPrefixes = getGeohashPrefixesInBbox(bbox); const requiredPrefixes = getGeohashPrefixesInBbox(bbox);
@@ -279,8 +173,6 @@ export default class StorageService extends Service {
// Full reload // Full reload
this.placesInView = places; this.placesInView = places;
} }
// Refresh list associations
this.refreshPlaceListAssociations();
} else { } else {
if (!prefixes) this.placesInView = []; if (!prefixes) this.placesInView = [];
} }
@@ -298,22 +190,11 @@ export default class StorageService extends Service {
let place = this.savedPlaces.find((p) => p.id && String(p.id) === strId); let place = this.savedPlaces.find((p) => p.id && String(p.id) === strId);
if (place) return place; if (place) return place;
// Check placesInView as fallback
place = this.placesInView.find((p) => p.id && String(p.id) === strId);
if (place) return place;
// Then search by OSM ID // Then search by OSM ID
place = this.savedPlaces.find((p) => p.osmId && String(p.osmId) === strId); place = this.savedPlaces.find((p) => p.osmId && String(p.osmId) === strId);
if (place) return place;
place = this.placesInView.find((p) => p.osmId && String(p.osmId) === strId);
return place; return place;
} }
isPlaceSaved(id) {
return !!this.findPlaceById(id);
}
async storePlace(placeData) { async storePlace(placeData) {
const savedPlace = await this.places.store(placeData); const savedPlace = await this.places.store(placeData);
@@ -368,82 +249,6 @@ 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;

View File

@@ -1,21 +0,0 @@
import Service from '@ember/service';
import { tracked } from '@glimmer/tracking';
export default class ToastService extends Service {
@tracked message = null;
@tracked isVisible = false;
timeoutId = null;
show(message, duration = 3000) {
this.message = message;
this.isVisible = true;
if (this.timeoutId) {
clearTimeout(this.timeoutId);
}
this.timeoutId = setTimeout(() => {
this.isVisible = false;
}, duration);
}
}

View File

@@ -1,15 +1,5 @@
/* Ember supports plain CSS out of the box. More info: https://cli.emberjs.com/release/advanced-use/stylesheets/ */ /* Ember supports plain CSS out of the box. More info: https://cli.emberjs.com/release/advanced-use/stylesheets/ */
:root {
--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,
body { body {
height: 100%; height: 100%;
@@ -72,96 +62,27 @@ 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.5rem 0.5rem 0; padding: 0 0.5rem;
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 {
.header-center { pointer-events: auto; /* Re-enable clicks for buttons */
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 {
@@ -259,7 +180,7 @@ body {
} }
.text-primary { .text-primary {
color: var(--link-color); color: #007bff;
} }
.text-danger { .text-danger {
@@ -276,21 +197,20 @@ body {
top: 0; top: 0;
left: 0; left: 0;
bottom: 0; bottom: 0;
width: var(--sidebar-width); width: 300px;
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%);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
overflow: hidden; /* Ensure flex children are contained */
} }
.sidebar.app-menu-pane { .settings-pane.sidebar {
z-index: 3200; /* Higher than Places Sidebar (3100) */ z-index: 3200; /* Higher than Places Sidebar (3100) */
} }
@media (width <= 768px) { @media (width <= 768px) {
.sidebar.app-menu-pane { .settings-pane.sidebar {
width: 100%; width: 100%;
right: 0; right: 0;
border-radius: 16px 16px 0 0; border-radius: 16px 16px 0 0;
@@ -319,118 +239,13 @@ body {
.sidebar-content { .sidebar-content {
padding: 1rem; padding: 1rem;
overflow-y: auto; overflow-y: auto;
-webkit-overflow-scrolling: touch; flex: 1; /* Take up remaining vertical space */
flex: 1;
min-height: 0;
touch-action: pan-y;
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: var(--hover-bg); background: #f8f9fa;
padding: 1rem; padding: 1rem;
border-bottom: 1px solid #eee; border-bottom: 1px solid #eee;
} }
@@ -454,25 +269,12 @@ 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: var(--link-color); border-color: #007bff;
box-shadow: 0 0 0 2px rgb(42 127 255 / 10%); box-shadow: 0 0 0 2px rgb(0 123 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 {
@@ -483,27 +285,27 @@ select.form-control {
.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;
} }
.about-section { .settings-section p a {
margin-bottom: 2rem; color: #007bff;
}
.about-section a {
color: var(--link-color);
text-decoration: none; text-decoration: none;
} }
.about-section a:visited { .settings-section p a:hover {
color: var(--link-color-visited);
}
.about-section a:hover {
text-decoration: underline; text-decoration: underline;
} }
@@ -512,7 +314,7 @@ select.form-control {
} }
.btn-primary { .btn-primary {
background: var(--link-color); background: #007bff;
color: white; color: white;
border: none; border: none;
padding: 0.75rem; padding: 0.75rem;
@@ -541,7 +343,7 @@ select.form-control {
} }
.meta-info a { .meta-info a {
color: var(--link-color); color: #007bff;
text-decoration: none; text-decoration: none;
} }
@@ -549,34 +351,24 @@ select.form-control {
text-decoration: underline; text-decoration: underline;
} }
.sidebar-content table { .link-list {
width: 100%; list-style: none;
border-collapse: collapse; padding: 0;
margin: 0;
} }
.sidebar-content table th, .link-list li {
.sidebar-content table td { margin-bottom: 0.5rem;
padding: 0.5rem 0;
text-align: left;
} }
.sidebar-content table th { .link-list a {
font-size: 0.75rem; color: #007bff;
font-weight: bold; text-decoration: none;
text-transform: uppercase; font-size: 0.95rem;
color: #898989;
} }
.sidebar-content table td { .link-list a:hover {
border-bottom: 1px solid #f9f9f9; text-decoration: underline;
}
.sidebar-content table tr:last-child td {
border-bottom: none;
}
abbr[title] {
text-decoration: underline dotted;
} }
.places-list { .places-list {
@@ -599,7 +391,7 @@ abbr[title] {
} }
.place-item:hover { .place-item:hover {
background: var(--hover-bg); background: #eee;
} }
.place-name { .place-name {
@@ -674,12 +466,6 @@ abbr[title] {
gap: 0.5rem; gap: 0.5rem;
} }
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
pointer-events: none;
}
.btn-outline { .btn-outline {
background: transparent; background: transparent;
color: #333; color: #333;
@@ -701,7 +487,7 @@ abbr[title] {
} }
.btn-blue { .btn-blue {
background: var(--link-color); background: #007bff;
color: white; color: white;
border: none; border: none;
} }
@@ -811,17 +597,6 @@ abbr[title] {
/* 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;
} }
@@ -874,15 +649,15 @@ span.icon {
.selected-pin { .selected-pin {
width: 40px; width: 40px;
height: 40px; height: 40px;
color: var(--marker-color-primary); color: #ea4335; /* Google Red */
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: var(--marker-color-primary); fill: #ea4335;
stroke: var(--marker-color-dark); stroke: #b31412; /* Darker red stroke */
stroke-width: 1; stroke-width: 1;
} }
@@ -946,14 +721,15 @@ span.icon {
display: block; display: block;
} }
/* Sidebar is open (Desktop: Left var(--sidebar-width)) */ /* Sidebar is open (Desktop: Left 300px) */
/* We want to center in the remaining space (width - var(--sidebar-width)) */ /* We want to center in the remaining space (width - 300px) */
/* Center X = var(--sidebar-width) + (width - var(--sidebar-width)) / 2 = var(--sidebar-width)/2 + 50% */ /* Center X = 300 + (width - 300) / 2 = 300 + width/2 - 150 = width/2 + 150 */
/* So shift left by 150px from center */
.map-container.sidebar-open .map-crosshair { .map-container.sidebar-open .map-crosshair {
left: calc(50% + var(--sidebar-width) / 2); left: calc(50% + 150px);
} }
@media (width <= 768px) { @media (width <= 768px) {
@@ -996,6 +772,7 @@ button.create-place {
.sidebar-content { .sidebar-content {
overflow-y: auto; overflow-y: auto;
overscroll-behavior: contain; /* Prevent scroll chaining */
/* Ensure content doesn't get hidden behind bottom safe areas on mobile */ /* Ensure content doesn't get hidden behind bottom safe areas on mobile */
padding-bottom: env(safe-area-inset-bottom, 20px); padding-bottom: env(safe-area-inset-bottom, 20px);
@@ -1161,7 +938,7 @@ button.create-place {
.search-result-item:hover, .search-result-item:hover,
.search-result-item:focus { .search-result-item:focus {
background: var(--hover-bg); background: #f5f5f5;
outline: none; outline: none;
} }
@@ -1200,157 +977,3 @@ button.create-place {
text-overflow: ellipsis; text-overflow: ellipsis;
margin-top: 2px; margin-top: 2px;
} }
/* Place Lists Manager */
.save-button-wrapper {
position: relative;
}
.place-lists-manager {
position: absolute;
top: 100%;
left: 0;
margin-top: 0.5rem;
background: white;
border-radius: 8px;
box-shadow: 0 4px 12px rgb(0 0 0 / 15%);
width: 220px;
z-index: 10;
padding: 0.5rem 0;
}
.place-lists-manager .list-item {
padding: 0.5rem 1rem;
display: flex;
align-items: center;
}
.place-lists-manager .list-item:hover {
background: var(--hover-bg);
}
.place-lists-manager label {
display: flex;
align-items: center;
gap: 0.75rem;
width: 100%;
cursor: pointer;
margin: 0;
font-size: 0.95rem;
color: #333;
}
.place-lists-manager input[type='checkbox'] {
accent-color: var(--link-color);
width: 16px;
height: 16px;
cursor: pointer;
}
.place-lists-manager .list-color {
width: 12px;
height: 12px;
background-color: var(--default-list-color);
border-radius: 50%;
flex-shrink: 0;
border: 1px solid rgb(0 0 0 / 10%);
}
.place-lists-manager .divider {
height: 1px;
background: #eee;
margin: 0.5rem 0;
}
/* Category Chips */
.category-chips-scroll {
width: 100%;
overflow-x: auto;
/* Add padding for shadows */
padding: 4px 0;
-webkit-overflow-scrolling: touch;
/* Hide scrollbar but keep functionality */
scrollbar-width: none; /* Firefox */
-ms-overflow-style: none; /* IE/Edge */
/* Remove top margin as spacing is handled by grid/layout */
margin-top: 0;
}
.category-chips-scroll::-webkit-scrollbar {
display: none;
}
.category-chips-container {
display: flex;
gap: 8px;
/* Padding on sides so first/last chip isn't flush with screen edge */
padding: 0 4px;
width: max-content; /* Ensure it scrolls */
}
.category-chip {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
background: white;
border: 1px solid #ddd;
border-radius: 16px; /* Pill shape */
font-size: 0.9rem;
color: #333;
cursor: pointer;
white-space: nowrap;
box-shadow: 0 1px 3px rgb(0 0 0 / 10%);
transition: background-color 0.2s;
}
.category-chip:hover {
background: var(--hover-bg);
}
.category-chip:active {
background: #eee;
}
.category-chip:disabled {
opacity: 0.75;
cursor: not-allowed;
pointer-events: none;
}
/* Toast Notification */
.toast-notification {
position: fixed;
bottom: 2rem;
left: 50%;
transform: translateX(-50%);
background-color: rgb(51 51 51 / 85%);
backdrop-filter: blur(4px);
color: white;
padding: 0.75rem 1.5rem;
border-radius: 999px;
z-index: 9999;
box-shadow: 0 4px 12px rgb(0 0 0 / 15%);
animation: fade-in-up 0.3s ease-out forwards;
text-align: center;
max-width: 90%;
font-size: 0.9rem;
font-weight: 500;
pointer-events: none;
}
@keyframes fade-in-up {
from {
opacity: 0;
transform: translate(-50%, 1rem);
}
to {
opacity: 1;
transform: translate(-50%, 0);
}
}

View File

@@ -2,8 +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 AppMenu from '#components/app-menu/index'; import SettingsPane from '#components/settings-pane';
import Toast from '#components/toast';
import { service } from '@ember/service'; import { service } from '@ember/service';
import { tracked } from '@glimmer/tracking'; import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object'; import { action } from '@ember/object';
@@ -15,7 +14,7 @@ export default class ApplicationComponent extends Component {
@service mapUi; @service mapUi;
@service router; @service router;
@tracked isAppMenuOpen = false; @tracked isSettingsOpen = 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.
@@ -35,19 +34,19 @@ export default class ApplicationComponent extends Component {
} }
@action @action
toggleAppMenu() { toggleSettings() {
this.isAppMenuOpen = !this.isAppMenuOpen; this.isSettingsOpen = !this.isSettingsOpen;
} }
@action @action
closeAppMenu() { closeSettings() {
this.isAppMenuOpen = false; this.isSettingsOpen = false;
} }
@action @action
handleOutsideClick() { handleOutsideClick() {
if (this.isAppMenuOpen) { if (this.isSettingsOpen) {
this.closeAppMenu(); this.closeSettings();
} 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') {
@@ -66,7 +65,7 @@ export default class ApplicationComponent extends Component {
<template> <template>
{{pageTitle "Marco"}} {{pageTitle "Marco"}}
<AppHeader @onToggleMenu={{this.toggleAppMenu}} /> <AppHeader @onToggleMenu={{this.toggleSettings}} />
<div <div
id="rs-widget-container" id="rs-widget-container"
@@ -82,16 +81,14 @@ export default class ApplicationComponent extends Component {
{{/if}} {{/if}}
<Map <Map
@isSidebarOpen={{or this.isSidebarOpen this.isAppMenuOpen}} @isSidebarOpen={{or this.isSidebarOpen this.isSettingsOpen}}
@onOutsideClick={{this.handleOutsideClick}} @onOutsideClick={{this.handleOutsideClick}}
/> />
{{#if this.isAppMenuOpen}} {{#if this.isSettingsOpen}}
<AppMenu @onClose={{this.closeAppMenu}} /> <SettingsPane @onClose={{this.closeSettings}} />
{{/if}} {{/if}}
<Toast />
{{outlet}} {{outlet}}
</template> </template>
} }

View File

@@ -77,11 +77,9 @@ export default class PlaceTemplate extends Component {
navigateBack(place) { navigateBack(place) {
// The sidebar calls this with null when "Back" is clicked. // The sidebar calls this with null when "Back" is clicked.
if (place === null) { if (place === null) {
// If we have an active search context, return to it (UP navigation) // If we came from search results, go back in history
if (this.mapUi.returnToSearch && this.mapUi.currentSearch) { if (this.mapUi.returnToSearch) {
this.router.transitionTo('search', { window.history.back();
queryParams: this.mapUi.currentSearch,
});
} else { } else {
// Otherwise just close the sidebar (return to map index) // Otherwise just close the sidebar (return to map index)
this.router.transitionTo('index'); this.router.transitionTo('index');

View File

@@ -11,8 +11,6 @@ export default class SearchTemplate extends Component {
selectPlace(place) { selectPlace(place) {
if (place) { if (place) {
this.mapUi.returnToSearch = true; this.mapUi.returnToSearch = true;
// We don't need to manually set currentSearch here because
// it was already set in the route's setupController
this.router.transitionTo('place', place); this.router.transitionTo('place', place);
} }
} }

View File

@@ -1,237 +0,0 @@
import activity from 'feather-icons/dist/icons/activity.svg?raw';
import arrowLeft from 'feather-icons/dist/icons/arrow-left.svg?raw';
import bookmark from 'feather-icons/dist/icons/bookmark.svg?raw';
import checkSquare from 'feather-icons/dist/icons/check-square.svg?raw';
import cigaretteWithSmokeCurl from '@waysidemapping/pinhead/dist/icons/cigarette_with_smoke_curl.svg?raw';
import clock from 'feather-icons/dist/icons/clock.svg?raw';
import edit from 'feather-icons/dist/icons/edit.svg?raw';
import eyeglasses from '@waysidemapping/pinhead/dist/icons/eyeglasses.svg?raw';
import facebook from 'feather-icons/dist/icons/facebook.svg?raw';
import fancyMirrorWithReflectionAndStars from '@waysidemapping/pinhead/dist/icons/fancy_mirror_with_reflection_and_stars.svg?raw';
import familyRestroomSymbol from '@waysidemapping/pinhead/dist/icons/family_restroom_symbol.svg?raw';
import gift from 'feather-icons/dist/icons/gift.svg?raw';
import 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 info from 'feather-icons/dist/icons/info.svg?raw';
import instagram from 'feather-icons/dist/icons/instagram.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 lowriseBuilding from '@waysidemapping/pinhead/dist/icons/lowrise_building.svg?raw';
import mail from 'feather-icons/dist/icons/mail.svg?raw';
import map from 'feather-icons/dist/icons/map.svg?raw';
import mapPin from 'feather-icons/dist/icons/map-pin.svg?raw';
import menu from 'feather-icons/dist/icons/menu.svg?raw';
import navigation from 'feather-icons/dist/icons/navigation.svg?raw';
import needleAndSpoolOfThread from '@waysidemapping/pinhead/dist/icons/needle_and_spool_of_thread.svg?raw';
import phone from 'feather-icons/dist/icons/phone.svg?raw';
import plus from 'feather-icons/dist/icons/plus.svg?raw';
import search from 'feather-icons/dist/icons/search.svg?raw';
import server from 'feather-icons/dist/icons/server.svg?raw';
import settings from 'feather-icons/dist/icons/settings.svg?raw';
import target from 'feather-icons/dist/icons/target.svg?raw';
import user from 'feather-icons/dist/icons/user.svg?raw';
import villageBuildings from '@waysidemapping/pinhead/dist/icons/village_buildings.svg?raw';
import x from 'feather-icons/dist/icons/x.svg?raw';
import zap from 'feather-icons/dist/icons/zap.svg?raw';
import loadingRing from '../icons/270-ring.svg?raw';
import angelfish from '@waysidemapping/pinhead/dist/icons/angelfish.svg?raw';
import barbell from '@waysidemapping/pinhead/dist/icons/barbell.svg?raw';
import banknote from '@waysidemapping/pinhead/dist/icons/banknote.svg?raw';
import badgeShieldWithFire from '@waysidemapping/pinhead/dist/icons/badge_shield_with_fire.svg?raw';
import beachUmbrellaInGround from '@waysidemapping/pinhead/dist/icons/beach_umbrella_in_ground.svg?raw';
import beerMugWithFoam from '@waysidemapping/pinhead/dist/icons/beer_mug_with_foam.svg?raw';
import burgerAndDrinkCupWithStraw from '@waysidemapping/pinhead/dist/icons/burger_and_drink_cup_with_straw.svg?raw';
import bus from '@waysidemapping/pinhead/dist/icons/bus.svg?raw';
import boxingGloveUp from '@waysidemapping/pinhead/dist/icons/boxing_glove_up.svg?raw';
import camera from '@waysidemapping/pinhead/dist/icons/camera.svg?raw';
import classicalBuilding from '@waysidemapping/pinhead/dist/icons/classical_building.svg?raw';
import classicalBuildingWithDomeAndFlag from '@waysidemapping/pinhead/dist/icons/classical_building_with_dome_and_flag.svg?raw';
import classicalBuildingWithFlag from '@waysidemapping/pinhead/dist/icons/classical_building_with_flag.svg?raw';
import commercialBuilding from '@waysidemapping/pinhead/dist/icons/commercial_building.svg?raw';
import clothesHanger from '@waysidemapping/pinhead/dist/icons/clothes_hanger.svg?raw';
import cleaver from '@waysidemapping/pinhead/dist/icons/cleaver.svg?raw';
import cloth from '@waysidemapping/pinhead/dist/icons/cloth.svg?raw';
import cocktail from '@waysidemapping/pinhead/dist/icons/cocktail.svg?raw';
import coffeeBean from '@waysidemapping/pinhead/dist/icons/coffee_bean.svg?raw';
import comedyMaskAndTragedyMask from '@waysidemapping/pinhead/dist/icons/comedy_mask_and_tragedy_mask.svg?raw';
import croissant from '@waysidemapping/pinhead/dist/icons/croissant.svg?raw';
import cupAndSaucer from '@waysidemapping/pinhead/dist/icons/cup_and_saucer.svg?raw';
import donut from '@waysidemapping/pinhead/dist/icons/donut.svg?raw';
import film from '@waysidemapping/pinhead/dist/icons/film.svg?raw';
import fingernailPolished from '@waysidemapping/pinhead/dist/icons/fingernail_polished.svg?raw';
import fish from '@waysidemapping/pinhead/dist/icons/fish.svg?raw';
import flagCheckered from '@waysidemapping/pinhead/dist/icons/flag_checkered.svg?raw';
import flowerBouquet from '@waysidemapping/pinhead/dist/icons/flower_bouquet.svg?raw';
import fort from '@waysidemapping/pinhead/dist/icons/fort.svg?raw';
import forkAndKnife from '@waysidemapping/pinhead/dist/icons/fork_and_knife.svg?raw';
import gravestone from '@waysidemapping/pinhead/dist/icons/gravestone.svg?raw';
import grecianVase from '@waysidemapping/pinhead/dist/icons/grecian_vase.svg?raw';
import greekCross from '@waysidemapping/pinhead/dist/icons/greek_cross.svg?raw';
import iceCreamOnCone from '@waysidemapping/pinhead/dist/icons/ice_cream_on_cone.svg?raw';
import jewel from '@waysidemapping/pinhead/dist/icons/jewel.svg?raw';
import marketStall from '@waysidemapping/pinhead/dist/icons/market_stall.svg?raw';
import memorialStoneWithInscription from '@waysidemapping/pinhead/dist/icons/memorial_stone_with_inscription.svg?raw';
import mobilePhoneWithKeypadAndAntenna from '@waysidemapping/pinhead/dist/icons/mobile_phone_with_keypad_and_antenna.svg?raw';
import molarTooth from '@waysidemapping/pinhead/dist/icons/molar_tooth.svg?raw';
import openBook from '@waysidemapping/pinhead/dist/icons/open_book.svg?raw';
import palace from '@waysidemapping/pinhead/dist/icons/palace.svg?raw';
import personCricketBattingAtCricketBall from '@waysidemapping/pinhead/dist/icons/person_cricket_batting_at_cricket_ball.svg?raw';
import personBoardingTramWithDestinationDisplayAndPantographOnTramTrack from '@waysidemapping/pinhead/dist/icons/person_boarding_tram_with_destination_display_and_pantograph_on_tram_track.svg?raw';
import personJockeyingRacehorse from '@waysidemapping/pinhead/dist/icons/person_jockeying_racehorse.svg?raw';
import personPlayingTennis from '@waysidemapping/pinhead/dist/icons/person_playing_tennis.svg?raw';
import personRunning from '@waysidemapping/pinhead/dist/icons/person_running.svg?raw';
import personSleepingInBed from '@waysidemapping/pinhead/dist/icons/person_sleeping_in_bed.svg?raw';
import personSwimmingInWater from '@waysidemapping/pinhead/dist/icons/person_swimming_in_water.svg?raw';
import personSwingingGolfClub from '@waysidemapping/pinhead/dist/icons/person_swinging_golf_club.svg?raw';
import plantInRaisedPlanter from '@waysidemapping/pinhead/dist/icons/plant_in_raised_planter.svg?raw';
import placeOfWorshipBuilding from '@waysidemapping/pinhead/dist/icons/place_of_worship_building.svg?raw';
import playStructureWithSlide from '@waysidemapping/pinhead/dist/icons/play_structure_with_slide.svg?raw';
import policeOfficerWithStopArm from '@waysidemapping/pinhead/dist/icons/police_officer_with_stop_arm.svg?raw';
import planeTopRight from '@waysidemapping/pinhead/dist/icons/plane_top_right.svg?raw';
import roundStructureWithFlag from '@waysidemapping/pinhead/dist/icons/round_structure_with_flag.svg?raw';
import sailingShipInWater from '@waysidemapping/pinhead/dist/icons/sailing_ship_in_water.svg?raw';
import scissorsOpen from '@waysidemapping/pinhead/dist/icons/scissors_open.svg?raw';
import shipwreckInWater from '@waysidemapping/pinhead/dist/icons/shipwreck_in_water.svg?raw';
import shoppingBag from '@waysidemapping/pinhead/dist/icons/shopping_bag.svg?raw';
import shoppingBasket from '@waysidemapping/pinhead/dist/icons/shopping_basket.svg?raw';
import tableTennisPaddle from '@waysidemapping/pinhead/dist/icons/table_tennis_paddle.svg?raw';
import tattooMachine from '@waysidemapping/pinhead/dist/icons/tattoo_machine.svg?raw';
import toolbox from '@waysidemapping/pinhead/dist/icons/toolbox.svg?raw';
import treeAndBenchWithBackrest from '@waysidemapping/pinhead/dist/icons/tree_and_bench_with_backrest.svg?raw';
import shoppingCart from '@waysidemapping/pinhead/dist/icons/shopping_cart.svg?raw';
import wallHangingWithMountainsAndSun from '@waysidemapping/pinhead/dist/icons/wall_hanging_with_mountains_and_sun.svg?raw';
import womensAndMensRestroomSymbol from '@waysidemapping/pinhead/dist/icons/womens_and_mens_restroom_symbol.svg?raw';
import wikipedia from '../icons/wikipedia.svg?raw';
import parkingP from '@waysidemapping/pinhead/dist/icons/parking_p.svg?raw';
const ICONS = {
activity,
angelfish,
'arrow-left': arrowLeft,
barbell,
banknote,
'badge-shield-with-fire': badgeShieldWithFire,
'beach-umbrella-in-ground': beachUmbrellaInGround,
'beer-mug-with-foam': beerMugWithFoam,
bookmark,
'boxing-glove-up': boxingGloveUp,
'burger-and-drink-cup-with-straw': burgerAndDrinkCupWithStraw,
bus,
camera,
'check-square': checkSquare,
'cigarette-with-smoke-curl': cigaretteWithSmokeCurl,
'classical-building': classicalBuilding,
'classical-building-with-dome-and-flag': classicalBuildingWithDomeAndFlag,
'classical-building-with-flag': classicalBuildingWithFlag,
'commercial-building': commercialBuilding,
'clothes-hanger': clothesHanger,
cleaver,
cloth,
cocktail,
clock,
'coffee-bean': coffeeBean,
'comedy-mask-and-tragedy-mask': comedyMaskAndTragedyMask,
croissant,
'cup-and-saucer': cupAndSaucer,
donut,
edit,
eyeglasses,
facebook,
'fancy-mirror-with-reflection-and-stars': fancyMirrorWithReflectionAndStars,
'family-restroom-symbol': familyRestroomSymbol,
film,
'fingernail-polished': fingernailPolished,
fish,
'flag-checkered': flagCheckered,
'flower-bouquet': flowerBouquet,
'fork-and-knife': forkAndKnife,
fort,
gift,
globe,
gravestone,
'grecian-vase': grecianVase,
'greek-cross': greekCross,
heart,
home,
'ice-cream-on-cone': iceCreamOnCone,
info,
instagram,
jewel,
'log-in': logIn,
'log-out': logOut,
'lowrise-building': lowriseBuilding,
mail,
map,
'map-pin': mapPin,
'market-stall': marketStall,
'memorial-stone-with-inscription': memorialStoneWithInscription,
menu,
'mobile-phone-with-keypad-and-antenna': mobilePhoneWithKeypadAndAntenna,
'molar-tooth': molarTooth,
navigation,
'needle-and-spool-of-thread': needleAndSpoolOfThread,
'open-book': openBook,
palace,
'person-cricket-batting-at-cricket-ball': personCricketBattingAtCricketBall,
'person-boarding-tram-with-destination-display-and-pantograph-on-tram-track':
personBoardingTramWithDestinationDisplayAndPantographOnTramTrack,
'person-jockeying-racehorse': personJockeyingRacehorse,
'person-playing-tennis': personPlayingTennis,
'person-running': personRunning,
'person-sleeping-in-bed': personSleepingInBed,
'person-swimming-in-water': personSwimmingInWater,
'person-swinging-golf-club': personSwingingGolfClub,
phone,
'plane-top-right': planeTopRight,
'plant-in-raised-planter': plantInRaisedPlanter,
'place-of-worship-building': placeOfWorshipBuilding,
'play-structure-with-slide': playStructureWithSlide,
'police-officer-with-stop-arm': policeOfficerWithStopArm,
plus,
'round-structure-with-flag': roundStructureWithFlag,
'sailing-ship-in-water': sailingShipInWater,
'scissors-open': scissorsOpen,
'shipwreck-in-water': shipwreckInWater,
'shopping-bag': shoppingBag,
search,
server,
settings,
'shopping-basket': shoppingBasket,
'shopping-cart': shoppingCart,
'table-tennis-paddle': tableTennisPaddle,
'tattoo-machine': tattooMachine,
toolbox,
target,
'tree-and-bench-with-backrest': treeAndBenchWithBackrest,
user,
'village-buildings': villageBuildings,
'wall-hanging-with-mountains-and-sun': wallHangingWithMountainsAndSun,
'womens-and-mens-restroom-symbol': womensAndMensRestroomSymbol,
wikipedia,
parking_p: parkingP,
x,
zap,
'loading-ring': loadingRing,
};
const FILLED_ICONS = [
'fork-and-knife',
'wikipedia',
'cup-and-saucer',
'coffee-bean',
'shopping-basket',
'camera',
'person-sleeping-in-bed',
'loading-ring',
];
export function getIcon(name) {
return ICONS[name];
}
export function isIconFilled(name) {
return FILLED_ICONS.includes(name);
}

View File

@@ -1,222 +0,0 @@
import { getIcon } from './icons';
// Rules for mapping OSM tags to icons.
// Rules are evaluated in order. The first rule where all specified tags match is used.
export const POI_ICON_RULES = [
// Specific Cuisine
{ tags: { cuisine: 'donut' }, icon: 'donut' },
{ tags: { cuisine: 'doughnut' }, icon: 'donut' },
{ tags: { cuisine: 'coffee_shop' }, icon: 'coffee-bean' },
{ tags: { cuisine: 'coffee' }, icon: 'coffee-bean' },
// General Amenity/Shop Types
{ tags: { amenity: 'ice_cream' }, icon: 'ice-cream-on-cone' },
{ tags: { cuisine: 'ice_cream' }, icon: 'ice-cream-on-cone' },
{ tags: { shop: 'ice_cream' }, icon: 'ice-cream-on-cone' },
{ tags: { amenity: 'cafe' }, icon: 'cup-and-saucer' },
{ tags: { amenity: 'restaurant' }, icon: 'fork-and-knife' },
{ tags: { amenity: 'fast_food' }, icon: 'burger-and-drink-cup-with-straw' },
{ tags: { amenity: 'pub' }, icon: 'beer-mug-with-foam' },
{ tags: { amenity: 'bar' }, icon: 'cocktail' },
{ tags: { amenity: 'food_court' }, icon: 'fork-and-knife' },
{ tags: { amenity: 'childcare' }, icon: 'family-restroom-symbol' },
{ tags: { amenity: 'community_centre' }, icon: 'family-restroom-symbol' },
{ tags: { amenity: 'social_centre' }, icon: 'family-restroom-symbol' },
{ tags: { amenity: 'social_facility' }, icon: 'family-restroom-symbol' },
{ tags: { amenity: 'bank' }, icon: 'banknote' },
{ tags: { amenity: 'place_of_worship' }, icon: 'place-of-worship-building' },
{ tags: { amenity: 'fire_station' }, icon: 'badge-shield-with-fire' },
{ tags: { amenity: 'police' }, icon: 'police-officer-with-stop-arm' },
{ tags: { amenity: 'toilets' }, icon: 'womens-and-mens-restroom-symbol' },
{ tags: { amenity: 'school' }, icon: 'open-book' },
{ tags: { shop: 'coffee' }, icon: 'coffee-bean' },
{ tags: { shop: 'tea' }, icon: 'coffee-bean' },
{ tags: { shop: 'pastry' }, icon: 'donut' },
// Shopping
{ tags: { shop: 'supermarket' }, icon: 'shopping-cart' },
{ tags: { shop: 'convenience' }, icon: 'shopping-basket' },
{ tags: { shop: 'grocery' }, icon: 'shopping-basket' },
{ tags: { shop: 'greengrocer' }, icon: 'shopping-basket' },
{ tags: { shop: 'bakery' }, icon: 'croissant' },
{ tags: { shop: 'butcher' }, icon: 'cleaver' },
{ tags: { shop: 'seafood' }, icon: 'fish' },
{ tags: { shop: 'deli' }, icon: 'shopping-basket' },
{ tags: { shop: 'clothes' }, icon: 'clothes-hanger' },
{ tags: { shop: 'clothing' }, icon: 'clothes-hanger' },
{ tags: { shop: 'hairdresser' }, icon: 'scissors-open' },
{ tags: { shop: 'optician' }, icon: 'eyeglasses' },
{ tags: { shop: 'fabric' }, icon: 'cloth' },
{ tags: { shop: 'flea_market' }, icon: 'market-stall' },
{ tags: { shop: 'kiosk' }, icon: 'shopping-basket' },
{ tags: { shop: 'leather' }, icon: 'shopping-bag' },
{ tags: { shop: 'tailor' }, icon: 'needle-and-spool-of-thread' },
{ tags: { shop: 'jewelry' }, icon: 'jewel' },
{ tags: { shop: 'jewellery' }, icon: 'jewel' },
{ tags: { shop: 'tobacco' }, icon: 'cigarette-with-smoke-curl' },
{ tags: { shop: 'cannabis' }, icon: 'cigarette-with-smoke-curl' },
{ tags: { shop: 'florist' }, icon: 'flower-bouquet' },
{ tags: { shop: 'garden_centre' }, icon: 'plant-in-raised-planter' },
{ tags: { shop: 'estate_agent' }, icon: 'village-buildings' },
{
tags: { shop: 'mobile_phone' },
icon: 'mobile-phone-with-keypad-and-antenna',
},
{ tags: { beauty: 'nails' }, icon: 'fingernail-polished' },
{ tags: { shop: 'tattoo' }, icon: 'tattoo-machine' },
{
tags: { shop: 'beauty' },
icon: 'fancy-mirror-with-reflection-and-stars',
},
{ tags: { craft: 'tailor' }, icon: 'needle-and-spool-of-thread' },
{ tags: { office: 'estate_agent' }, icon: 'village-buildings' },
{ tags: { office: true }, icon: 'commercial-building' },
{ tags: { craft: true }, icon: 'toolbox' },
{ tags: { shop: true }, icon: 'shopping-bag' },
// Natural
{ tags: { natural: 'beach' }, icon: 'beach-umbrella-in-ground' },
{ tags: { leisure: 'park' }, icon: 'tree-and-bench-with-backrest' },
{ tags: { leisure: 'playground' }, icon: 'play-structure-with-slide' },
// Transport
{ tags: { aeroway: 'aerodrome' }, icon: 'plane-top-right' },
{ tags: { aeroway: 'heliport' }, icon: 'plane-top-right' },
{ tags: { aeroway: 'helipad' }, icon: 'plane-top-right' },
{ tags: { highway: 'bus_stop' }, icon: 'bus' },
{ tags: { bus: true }, icon: 'bus' },
{
tags: { railway: 'tram_stop' },
icon: 'person-boarding-tram-with-destination-display-and-pantograph-on-tram-track',
},
// Tourism
{ tags: { tourism: 'museum' }, icon: 'classical-building' },
{ tags: { tourism: 'gallery' }, icon: 'wall-hanging-with-mountains-and-sun' },
{ tags: { tourism: 'aquarium' }, icon: 'angelfish' },
{ tags: { tourism: 'theme_park' }, icon: 'camera' },
{ tags: { tourism: 'attraction' }, icon: 'camera' },
{ tags: { tourism: 'viewpoint' }, icon: 'camera' },
{ tags: { tourism: 'zoo' }, icon: 'camera' },
{ tags: { tourism: 'artwork' }, icon: 'camera' },
{ tags: { amenity: 'cinema' }, icon: 'film' },
{ tags: { amenity: 'theatre' }, icon: 'camera' },
{ tags: { amenity: 'arts_centre' }, icon: 'comedy-mask-and-tragedy-mask' },
{ tags: { amenity: 'arts_center' }, icon: 'comedy-mask-and-tragedy-mask' },
// Historic
{ tags: { historic: 'fort' }, icon: 'fort' },
{ tags: { historic: 'castle' }, icon: 'palace' },
{ tags: { historic: 'building' }, icon: 'classical-building-with-flag' },
{ tags: { historic: 'archaeological_site' }, icon: 'grecian-vase' },
{ tags: { historic: 'memorial' }, icon: 'memorial-stone-with-inscription' },
{ tags: { historic: 'tomb' }, icon: 'gravestone' },
{
tags: { historic: 'monument' },
icon: 'classical-building-with-dome-and-flag',
},
{ tags: { historic: 'ship' }, icon: 'sailing-ship-in-water' },
{ tags: { historic: 'wreck' }, icon: 'shipwreck-in-water' },
{ tags: { historic: 'ruins' }, icon: 'camera' },
{ tags: { historic: 'ruin' }, icon: 'camera' },
{ tags: { historic: 'yes' }, icon: 'camera' },
// Accommodation
{ tags: { tourism: 'hotel' }, icon: 'person-sleeping-in-bed' },
{ tags: { tourism: 'hostel' }, icon: 'person-sleeping-in-bed' },
{ tags: { tourism: 'motel' }, icon: 'person-sleeping-in-bed' },
{ tags: { tourism: 'guest_house' }, icon: 'person-sleeping-in-bed' },
// Sports / Motorsports
{ tags: { sport: 'motor' }, icon: 'flag-checkered' },
{ tags: { sport: 'karting' }, icon: 'flag-checkered' },
{ tags: { sport: 'motocross' }, icon: 'flag-checkered' },
{
tags: { sport: 'cricket' },
icon: 'person-cricket-batting-at-cricket-ball',
},
{ tags: { sport: 'boxing' }, icon: 'boxing-glove-up' },
{ tags: { sport: 'martial_arts' }, icon: 'boxing-glove-up' },
{ tags: { sport: 'tennis' }, icon: 'person-playing-tennis' },
{ tags: { sport: 'squash' }, icon: 'person-playing-tennis' },
{ tags: { sport: 'padel' }, icon: 'person-playing-tennis' },
{ tags: { sport: 'table_tennis' }, icon: 'table-tennis-paddle' },
{ tags: { leisure: 'water_park' }, icon: 'person-swimming-in-water' },
{ tags: { sport: 'swimming' }, icon: 'person-swimming-in-water' },
{ tags: { sport: 'golf' }, icon: 'person-swinging-golf-club' },
{ tags: { leisure: 'golf_course' }, icon: 'person-swinging-golf-club' },
{ tags: { sport: 'horse_racing' }, icon: 'person-jockeying-racehorse' },
{ tags: { sport: 'fitness' }, icon: 'barbell' },
{ tags: { sport: 'fitness_centre' }, icon: 'barbell' },
{ tags: { leisure: 'fitness_centre' }, icon: 'barbell' },
{ tags: { sport: 'stadium' }, icon: 'round-structure-with-flag' },
{ tags: { leisure: 'stadium' }, icon: 'round-structure-with-flag' },
{ tags: { leisure: 'sports_centre' }, icon: 'person-running' },
{ tags: { leisure: 'pitch' }, icon: 'person-running' },
{ tags: { sport: true }, icon: 'person-running' },
// Healthcare
{ tags: { amenity: 'dentist' }, icon: 'molar-tooth' },
{ tags: { healthcare: 'dentist' }, icon: 'molar-tooth' },
{ tags: { healthcare: true }, icon: 'greek-cross' },
// Parking
{ tags: { amenity: 'parking' }, icon: 'parking_p' },
// Buildings
{ tags: { building: 'commercial' }, icon: 'commercial-building' },
{ tags: { building: 'apartments' }, icon: 'lowrise-building' },
];
/**
* Finds the appropriate icon name based on the place's OSM tags.
* @param {Object} tags - The OSM tags of the place.
* @returns {string|null} - The name of the icon or null if no match found.
*/
export function getIconNameForTags(tags) {
if (!tags) return null;
for (const rule of POI_ICON_RULES) {
let match = true;
for (const [key, expectedValue] of Object.entries(rule.tags)) {
const tagValue = tags[key];
if (!tagValue) {
match = false;
break;
}
// Check for exact match or if value is in a semicolon-separated list
// e.g. "donut;coffee_shop"
const values = tagValue.split(';').map((v) => v.trim());
// If expectedValue is boolean true, any value is a match
if (expectedValue === true) {
continue;
}
if (!values.includes(expectedValue)) {
match = false;
break;
}
}
if (match) {
return rule.icon;
}
}
return null;
}
/**
* Returns the raw SVG string for the icon corresponding to the given tags.
* @param {Object} tags - The OSM tags.
* @returns {string|null} - The raw SVG string or null.
*/
export function getIconSvgForTags(tags) {
const iconName = getIconNameForTags(tags);
if (!iconName) return null;
return getIcon(iconName);
}

View File

@@ -1,15 +0,0 @@
import { getLocalizedName } from './osm';
export function mapToStorageSchema(place) {
return {
title: place.title || getLocalizedName(place.osmTags, 'Untitled Place'),
lat: place.lat,
lon: place.lon,
tags: [],
url: place.osmTags?.website,
osmId: String(place.osmId || place.id),
osmType: place.osmType,
osmTags: place.osmTags || {},
description: place.description,
};
}

View File

@@ -1,65 +0,0 @@
// This configuration defines the "Quick Search" categories available in the UI.
//
// Structure:
// - id: The URL slug used for routing (e.g. ?category=restaurants)
// - label: The human-readable name displayed in the UI
// - icon: The icon name (must be registered in app/utils/icons.js)
// - filter: An array of Overpass QL query parts.
// - Each string in the array is an independent query condition.
// - Multiple strings act as an OR condition (union of results).
export const POI_CATEGORIES = [
{
id: 'restaurants',
label: 'Restaurants',
icon: 'fork-and-knife',
filter: [
'["amenity"~"^(restaurant|fast_food|food_court|pub|cafe)$"]["cuisine"!~"coffee"]',
],
types: ['node', 'way'],
},
{
id: 'coffee',
label: 'Coffee',
icon: 'cup-and-saucer',
filter: [
'["amenity"~"^(cafe|ice_cream)$"]',
'["shop"~"^(coffee|tea)$"]',
'["cuisine"~"coffee_shop"]',
],
types: ['node', 'way'],
},
{
id: 'groceries',
label: 'Groceries',
icon: 'shopping-basket',
filter: [
'["shop"~"^(supermarket|convenience|grocery|greengrocer|bakery|butcher|deli|farm|seafood)$"]',
],
types: ['node', 'way'],
},
{
id: 'things-to-do',
label: 'Things to do',
icon: 'camera',
filter: [
'["tourism"~"^(museum|gallery|attraction|viewpoint|zoo|theme_park|aquarium|artwork)$"]',
'["amenity"~"^(cinema|theatre|arts_centre|planetarium)$"]',
'["leisure"~"^(sports_centre|stadium|water_park)$"]',
'["historic"]',
'["shop"="flea_market"]',
],
types: ['node', 'way', 'relation'],
},
{
id: 'accommodation',
label: 'Hotels',
icon: 'person-sleeping-in-bed',
filter: ['["tourism"~"^(hotel|hostel|motel)$"]'],
types: ['node', 'way', 'relation'],
},
];
export function getCategoryById(id) {
return POI_CATEGORIES.find((c) => c.id === id);
}

View File

@@ -1,6 +1,6 @@
{ {
"name": "marco", "name": "marco",
"version": "1.17.0", "version": "1.13.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": "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", "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",
"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 lint && pnpm test", "preversion": "pnpm test",
"version": "pnpm build && git add release/" "version": "pnpm build && git add release/"
}, },
"devDependencies": { "devDependencies": {
@@ -52,7 +52,7 @@
"@embroider/vite": "^1.5.0", "@embroider/vite": "^1.5.0",
"@eslint/js": "^9.39.2", "@eslint/js": "^9.39.2",
"@glimmer/component": "^2.0.0", "@glimmer/component": "^2.0.0",
"@remotestorage/module-places": "~1.2.1", "@remotestorage/module-places": "1.x",
"@rollup/plugin-babel": "^6.1.0", "@rollup/plugin-babel": "^6.1.0",
"@warp-drive/core": "~5.8.0", "@warp-drive/core": "~5.8.0",
"@warp-drive/ember": "~5.8.0", "@warp-drive/ember": "~5.8.0",
@@ -102,7 +102,6 @@
"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"
} }

18
pnpm-lock.yaml generated
View File

@@ -8,9 +8,6 @@ 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)
@@ -58,8 +55,8 @@ importers:
specifier: ^2.0.0 specifier: ^2.0.0
version: 2.0.0 version: 2.0.0
'@remotestorage/module-places': '@remotestorage/module-places':
specifier: ~1.2.1 specifier: 1.x
version: 1.2.1 version: 1.0.0
'@rollup/plugin-babel': '@rollup/plugin-babel':
specifier: ^6.1.0 specifier: ^6.1.0
version: 6.1.0(@babel/core@7.28.6)(rollup@4.55.1) version: 6.1.0(@babel/core@7.28.6)(rollup@4.55.1)
@@ -1383,8 +1380,8 @@ packages:
resolution: {integrity: sha512-4rdu8GPY9TeQwsYp5D2My74dC3dSVS3tghAvisG80ybK4lqa0gvlrglaSTBxogJbxqHRw/NjI/liEtb3+SD+Bw==} resolution: {integrity: sha512-4rdu8GPY9TeQwsYp5D2My74dC3dSVS3tghAvisG80ybK4lqa0gvlrglaSTBxogJbxqHRw/NjI/liEtb3+SD+Bw==}
engines: {node: '>=18.12'} engines: {node: '>=18.12'}
'@remotestorage/module-places@1.2.1': '@remotestorage/module-places@1.0.0':
resolution: {integrity: sha512-hNRuhGoG8RS+cieVvDVzXWBEuNPfyeFirhgNH3z1WoKw9ngHdPY6V0sT0vKbsxB8xaODReZfo2ZKHLTmdFunlw==} resolution: {integrity: sha512-vaqJeTw658gjPyLz70Mq2AbGfDZ66O2mpDFME+gtaGFYl2+UvrvRLCrXWHYuyTE21f3TJdegeXM6C5nZMxLv9A==}
'@rollup/plugin-babel@6.1.0': '@rollup/plugin-babel@6.1.0':
resolution: {integrity: sha512-dFZNuFD2YRcoomP4oYf+DvQNSUA9ih+A3vUqopQx5EdtPGo3WBnQcI/S8pwpz91UsGfL0HsMSOlaMld8HrbubA==} resolution: {integrity: sha512-dFZNuFD2YRcoomP4oYf+DvQNSUA9ih+A3vUqopQx5EdtPGo3WBnQcI/S8pwpz91UsGfL0HsMSOlaMld8HrbubA==}
@@ -1654,9 +1651,6 @@ 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'}
@@ -7008,7 +7002,7 @@ snapshots:
'@pnpm/error': 1000.0.5 '@pnpm/error': 1000.0.5
find-up: 5.0.0 find-up: 5.0.0
'@remotestorage/module-places@1.2.1': '@remotestorage/module-places@1.0.0':
dependencies: dependencies:
latlon-geohash: 2.0.0 latlon-geohash: 2.0.0
ulid: 3.0.2 ulid: 3.0.2
@@ -7245,8 +7239,6 @@ snapshots:
- '@glint/template' - '@glint/template'
- supports-color - supports-color
'@waysidemapping/pinhead@15.17.0': {}
'@xmldom/xmldom@0.8.11': {} '@xmldom/xmldom@0.8.11': {}
abbrev@1.1.1: {} abbrev@1.1.1: {}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -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-DM7YMuyX.js"></script> <script type="module" crossorigin src="/assets/main-BKvJYcmy.js"></script>
<link rel="stylesheet" crossorigin href="/assets/main-OLSOzTKA.css"> <link rel="stylesheet" crossorigin href="/assets/main-BeloONRF.css">
</head> </head>
<body> <body>
</body> </body>

View File

@@ -1,182 +0,0 @@
import { module, test } from 'qunit';
import { visit, currentURL, waitFor, triggerEvent } from '@ember/test-helpers';
import { setupApplicationTest } from 'marco/tests/helpers';
import Service from '@ember/service';
import sinon from 'sinon';
module('Acceptance | map search reset', function (hooks) {
setupApplicationTest(hooks);
hooks.beforeEach(function () {
// Seed localStorage with a high zoom level to ensure map is interactive
const highZoomState = {
center: [13.4, 52.5],
zoom: 18,
};
window.localStorage.setItem(
'marco:map-view',
JSON.stringify(highZoomState)
);
// Stub window.fetch using Sinon
// We want to intercept map style requests and let everything else through
this.fetchStub = sinon.stub(window, 'fetch');
this.fetchStub.callsFake(async (input, init) => {
let url = input;
if (typeof input === 'object' && input !== null && 'url' in input) {
url = input.url;
}
if (
typeof url === 'string' &&
url.includes('tiles.openfreemap.org/styles/liberty')
) {
return {
ok: true,
status: 200,
json: async () => ({
version: 8,
name: 'Liberty',
sources: {
openmaptiles: {
type: 'vector',
url: 'https://tiles.openfreemap.org/planet',
},
},
layers: [
{
id: 'background',
type: 'background',
paint: {
'background-color': '#123456',
},
},
],
glyphs:
'https://tiles.openfreemap.org/fonts/{fontstack}/{range}.pbf',
sprite: 'https://tiles.openfreemap.org/sprites/liberty',
}),
};
}
// Pass through to the original implementation
return this.fetchStub.wrappedMethod(input, init);
});
});
hooks.afterEach(function () {
window.localStorage.removeItem('marco:map-view');
// Restore the original fetch
this.fetchStub.restore();
});
test('clicking the map clears the category search parameter', async function (assert) {
// Mock OSM Service
class MockOsmService extends Service {
async getCategoryPois() {
return [
{
title: 'Cafe Test',
lat: 52.52,
lon: 13.405,
osmId: '123',
osmType: 'N',
},
];
}
async getNearbyPois() {
return [];
}
}
this.owner.register('service:osm', MockOsmService);
// Mock Storage
this.owner.register(
'service:storage',
class extends Service {
rs = { on: () => {} };
placesInView = [];
savedPlaces = [];
loadPlacesInBounds() {
return Promise.resolve();
}
findPlaceById() {
return null;
}
}
);
// 1. Visit a category search URL
await visit('/search?category=coffee&lat=52.52&lon=13.405');
assert.dom('.sidebar-header').includesText('Results');
assert.ok(
currentURL().includes('category=coffee'),
'URL should have category param'
);
// 2. Click the map (First click closes sidebar)
await waitFor('canvas', { timeout: 2000 });
const canvas = document.querySelector('canvas');
if (canvas) {
// First Click (Close Sidebar)
await triggerEvent(canvas, 'pointerdown', {
clientX: 200,
clientY: 200,
button: 0,
isPrimary: true,
});
await triggerEvent(canvas, 'pointerup', {
clientX: 200,
clientY: 200,
button: 0,
isPrimary: true,
});
await triggerEvent(canvas, 'click', {
clientX: 200,
clientY: 200,
bubbles: true,
});
// Wait for transition to index
await new Promise((r) => setTimeout(r, 500));
assert.strictEqual(
currentURL(),
'/',
'Should have transitioned to index (closed sidebar)'
);
// Second Click (Start new search)
// Click slightly differently to ensure fresh event
await triggerEvent(canvas, 'pointerdown', {
clientX: 250,
clientY: 250,
button: 0,
isPrimary: true,
});
await triggerEvent(canvas, 'pointerup', {
clientX: 250,
clientY: 250,
button: 0,
isPrimary: true,
});
await triggerEvent(canvas, 'click', {
clientX: 250,
clientY: 250,
bubbles: true,
});
}
// 3. Wait for transition
await new Promise((r) => setTimeout(r, 1000));
const newUrl = currentURL();
assert.notOk(
newUrl.includes('category=coffee'),
`New URL ${newUrl} should not contain category param`
);
assert.ok(newUrl.includes('/search'), 'Should be on search route');
});
});

View File

@@ -42,9 +42,6 @@ class MockStorageService extends Service {
findPlaceById() { findPlaceById() {
return null; return null;
} }
isPlaceSaved() {
return false;
}
loadPlacesInBounds() { loadPlacesInBounds() {
return []; return [];
} }
@@ -64,9 +61,11 @@ module('Acceptance | navigation', function (hooks) {
this.owner.register('service:storage', MockStorageService); this.owner.register('service:storage', MockStorageService);
}); });
test('navigating from search results to place and back returns to search', async function (assert) { test('navigating from search results to place and back uses history', async function (assert) {
const mapUi = this.owner.lookup('service:map-ui'); const mapUi = this.owner.lookup('service:map-ui');
const backStub = sinon.stub(window.history, 'back');
try {
await visit('/search?lat=1&lon=1'); await visit('/search?lat=1&lon=1');
assert.strictEqual(currentURL(), '/search?lat=1&lon=1'); assert.strictEqual(currentURL(), '/search?lat=1&lon=1');
@@ -77,11 +76,10 @@ module('Acceptance | navigation', function (hooks) {
// Click the back button in the sidebar // Click the back button in the sidebar
await click('.back-btn'); await click('.back-btn');
assert.strictEqual( assert.true(backStub.calledOnce, 'window.history.back() was called');
currentURL(), } finally {
'/search?lat=1&lon=1', backStub.restore();
'Returned to search results' }
);
}); });
test('closing the sidebar resets the returnToSearch flag', async function (assert) { test('closing the sidebar resets the returnToSearch flag', async function (assert) {

View File

@@ -1,136 +0,0 @@
import { module, test } from 'qunit';
import { visit, click, fillIn, currentURL } from '@ember/test-helpers';
import { setupApplicationTest } from 'marco/tests/helpers';
import Service from '@ember/service';
import { Promise } from 'rsvp';
class MockPhotonService extends Service {
cancelAll() {}
async search(query) {
// Simulate network delay
await new Promise((resolve) => setTimeout(resolve, 50));
if (query === 'slow') {
await new Promise((resolve) => setTimeout(resolve, 200));
}
return [
{
title: 'Test Place',
lat: 1,
lon: 1,
osmId: '123',
osmType: 'node',
},
];
}
}
class MockOsmService extends Service {
cancelAll() {}
async getCategoryPois(bounds, category) {
await new Promise((resolve) => setTimeout(resolve, 50));
if (category === 'slow_category') {
await new Promise((resolve) => setTimeout(resolve, 200));
}
return [];
}
async getNearbyPois() {
return [];
}
}
module('Acceptance | search loading', function (hooks) {
setupApplicationTest(hooks);
hooks.beforeEach(function () {
this.owner.register('service:photon', MockPhotonService);
this.owner.register('service:osm', MockOsmService);
});
test('search shows loading indicator but nearby search does not', async function (assert) {
const mapUi = this.owner.lookup('service:map-ui');
// 1. Text Search
// Start a search and check for loading state immediately
const searchPromise = visit('/search?q=slow');
// We can't easily check the DOM mid-transition in acceptance tests without complicated helpers,
// so we check the service state which drives the UI.
// Wait a tiny bit for the route to start processing
await new Promise((r) => setTimeout(r, 10));
assert.deepEqual(
mapUi.loadingState,
{ type: 'text', value: 'slow' },
'Loading state is set for text search'
);
await searchPromise;
assert.strictEqual(
mapUi.loadingState,
null,
'Loading state is cleared after text search'
);
// 2. Category Search
const catPromise = visit('/search?category=slow_category&lat=1&lon=1');
await new Promise((r) => setTimeout(r, 10));
assert.deepEqual(
mapUi.loadingState,
{ type: 'category', value: 'slow_category' },
'Loading state is set for category search'
);
await catPromise;
assert.strictEqual(
mapUi.loadingState,
null,
'Loading state is cleared after category search'
);
// 3. Nearby Search
await visit('/search?lat=1&lon=1');
assert.strictEqual(
mapUi.loadingState,
null,
'Loading state is NOT set for nearby search'
);
});
test('clearing search stops loading indicator', async function (assert) {
const mapUi = this.owner.lookup('service:map-ui');
// 1. Start from index
await visit('/');
// 2. Type "slow" to trigger autocomplete (which is async)
await fillIn('.search-input', 'slow');
// 3. Submit search to trigger route loading
click('.search-submit-btn'); // Intentionally no await to not block on transition
// Wait for loading state to activate
await new Promise((r) => setTimeout(r, 100));
assert.deepEqual(
mapUi.loadingState,
{ type: 'text', value: 'slow' },
'Loading state is set'
);
// 4. Click the clear button (should be visible since input has value)
await click('.search-clear-btn');
// Verify loading state is cleared immediately
assert.strictEqual(
mapUi.loadingState,
null,
'Loading state is cleared immediately after clicking clear'
);
// Verify we are back on index (or at least query is gone)
assert.strictEqual(currentURL(), '/', 'Navigated to index');
});
});

View File

@@ -41,9 +41,6 @@ module('Acceptance | search', function (hooks) {
findPlaceById() { findPlaceById() {
return null; return null;
} }
isPlaceSaved() {
return false;
}
rs = { rs = {
on: () => {}, on: () => {},
}; };
@@ -88,9 +85,6 @@ module('Acceptance | search', function (hooks) {
findPlaceById() { findPlaceById() {
return null; return null;
} }
isPlaceSaved() {
return false;
}
rs = { rs = {
on: () => {}, on: () => {},
}; };
@@ -136,9 +130,6 @@ module('Acceptance | search', function (hooks) {
if (id === '999') return this.savedPlaces[0]; if (id === '999') return this.savedPlaces[0];
return null; return null;
} }
isPlaceSaved(id) {
return !!this.findPlaceById(id);
}
rs = { rs = {
on: () => {}, on: () => {},
}; };
@@ -155,119 +146,4 @@ module('Acceptance | search', function (hooks) {
assert.dom('.places-list li').exists({ count: 1 }); assert.dom('.places-list li').exists({ count: 1 });
assert.dom('.places-list li .place-name').hasText('My Secret Base'); assert.dom('.places-list li .place-name').hasText('My Secret Base');
}); });
test('visiting /search with category parameter performs category search', async function (assert) {
// Mock Osm Service
class MockOsmService extends Service {
async getCategoryPois(bounds, categoryId) {
if (categoryId === 'coffee') {
return [
{
title: 'Latte Art Cafe',
lat: 52.52,
lon: 13.405,
osmId: '101',
osmType: 'N',
description: 'Best Coffee',
},
];
}
return [];
}
}
this.owner.register('service:osm', MockOsmService);
// Mock Storage Service (empty)
class MockStorageService extends Service {
savedPlaces = [];
findPlaceById() {
return null;
}
isPlaceSaved() {
return false;
}
rs = { on: () => {} };
placesInView = [];
loadPlacesInBounds() {
return Promise.resolve();
}
}
this.owner.register('service:storage', MockStorageService);
// Mock Map Service (needed for bounds)
class MockMapService extends Service {
getBounds() {
return {
minLat: 52.5,
minLon: 13.4,
maxLat: 52.6,
maxLon: 13.5,
};
}
}
this.owner.register('service:map', MockMapService);
await visit('/search?category=coffee&lat=52.52&lon=13.405');
assert.strictEqual(
currentURL(),
'/search?category=coffee&lat=52.52&lon=13.405'
);
assert.dom('.places-list li').exists({ count: 1 });
assert.dom('.places-list li .place-name').hasText('Latte Art Cafe');
// Ensure it shows "Results" not "Nearby"
assert.dom('.sidebar-header h2').includesText('Results');
});
test('search error handling prevents opening empty panel and shows toast', async function (assert) {
// Mock Osm Service to throw an error
class MockOsmService extends Service {
async getCategoryPois() {
throw new Error('Overpass request failed');
}
}
this.owner.register('service:osm', MockOsmService);
class MockStorageService extends Service {
savedPlaces = [];
findPlaceById() {
return null;
}
isPlaceSaved() {
return false;
}
rs = { on: () => {} };
placesInView = [];
loadPlacesInBounds() {
return Promise.resolve();
}
}
this.owner.register('service:storage', MockStorageService);
class MockMapService extends Service {
getBounds() {
return {
minLat: 52.5,
minLon: 13.4,
maxLat: 52.6,
maxLon: 13.5,
};
}
}
this.owner.register('service:map', MockMapService);
await visit('/');
try {
await visit('/search?category=coffee&lat=52.52&lon=13.405');
} catch {
// Aborted transition throws, which is expected
}
assert.dom('.toast-notification').exists('Toast should be visible');
assert
.dom('.toast-notification')
.hasText('Search request failed. Please try again.');
assert.dom('.places-sidebar').doesNotExist('Results panel should not open');
});
}); });

View File

@@ -1,29 +1,13 @@
import { module, test } from 'qunit'; import { module, test } from 'qunit';
import { setupRenderingTest } from 'marco/tests/helpers'; import { setupRenderingTest } from 'marco/tests/helpers';
import { render, fillIn } from '@ember/test-helpers'; import { render } from '@ember/test-helpers';
import AppHeader from 'marco/components/app-header'; import AppHeader from 'marco/components/app-header';
import Service from '@ember/service';
module('Integration | Component | app-header', function (hooks) { module('Integration | Component | app-header', function (hooks) {
setupRenderingTest(hooks); setupRenderingTest(hooks);
test('it renders the search box', async function (assert) { test('it renders the search box', async function (assert) {
this.noop = () => {}; this.noop = () => {};
class MockPhotonService extends Service {}
class MockRouterService extends Service {}
class MockMapUiService extends Service {}
class MockMapService extends Service {}
class MockSettingsService extends Service {
showQuickSearchButtons = true;
}
this.owner.register('service:photon', MockPhotonService);
this.owner.register('service:router', MockRouterService);
this.owner.register('service:map-ui', MockMapUiService);
this.owner.register('service:map', MockMapService);
this.owner.register('service:settings', MockSettingsService);
await render( await render(
<template><AppHeader @onToggleMenu={{this.noop}} /></template> <template><AppHeader @onToggleMenu={{this.noop}} /></template>
); );
@@ -32,43 +16,4 @@ module('Integration | Component | app-header', function (hooks) {
assert.dom('.search-box').exists('Search box is present in the header'); assert.dom('.search-box').exists('Search box is present in the header');
assert.dom('.menu-btn-integrated').exists('Menu button is integrated'); assert.dom('.menu-btn-integrated').exists('Menu button is integrated');
}); });
test('typing in search box toggles .searching class on header-center', async function (assert) {
this.noop = () => {};
class MockPhotonService extends Service {
search() {
return [];
}
}
class MockRouterService extends Service {}
class MockMapUiService extends Service {
setSearchBoxFocus() {}
currentCenter = null;
}
class MockMapService extends Service {}
class MockSettingsService extends Service {
showQuickSearchButtons = true;
}
this.owner.register('service:photon', MockPhotonService);
this.owner.register('service:router', MockRouterService);
this.owner.register('service:map-ui', MockMapUiService);
this.owner.register('service:map', MockMapService);
this.owner.register('service:settings', MockSettingsService);
await render(
<template><AppHeader @onToggleMenu={{this.noop}} /></template>
);
assert.dom('.header-center').doesNotHaveClass('searching');
await fillIn('.search-input', 'test');
assert.dom('.header-center').hasClass('searching');
await fillIn('.search-input', '');
assert.dom('.header-center').doesNotHaveClass('searching');
});
}); });

View File

@@ -1,56 +0,0 @@
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');
});
});

View File

@@ -1,49 +1,11 @@
import { module, test } from 'qunit'; import { module, test } from 'qunit';
import { setupRenderingTest } from 'marco/tests/helpers'; import { setupRenderingTest } from 'marco/tests/helpers';
import { render, click } from '@ember/test-helpers'; import { render } from '@ember/test-helpers';
import Service from '@ember/service';
import PlaceDetails from 'marco/components/place-details'; import PlaceDetails from 'marco/components/place-details';
module('Integration | Component | place-details', function (hooks) { module('Integration | Component | place-details', function (hooks) {
setupRenderingTest(hooks); setupRenderingTest(hooks);
class StorageService extends Service {
lists = [
{ id: 'to-go', title: 'Want to go', color: '#2e9e4f' },
{ id: 'to-do', title: 'To do', color: '#2a7fff' },
];
isPlaceSaved() {
return false;
}
findPlaceById() {
return null;
}
async storePlace(place) {
return { ...place, id: '123', createdAt: new Date().toISOString() };
}
async removePlace() {
return true;
}
async togglePlaceList() {
return true;
}
}
hooks.beforeEach(function () {
this.owner.register('service:storage', StorageService);
// Mock Router for all tests
class MockRouter extends Service {
transitionTo() {}
}
this.owner.register('service:router', MockRouter);
});
test('it formats coordinates correctly', async function (assert) { test('it formats coordinates correctly', async function (assert) {
const place = { const place = {
title: 'Test Place', title: 'Test Place',
@@ -72,187 +34,4 @@ module('Integration | Component | place-details', function (hooks) {
assert.dom('.place-details h3').hasText('Place without Coords'); assert.dom('.place-details h3').hasText('Place without Coords');
assert.dom('.meta-info a[href*="geo:"]').doesNotExist(); assert.dom('.meta-info a[href*="geo:"]').doesNotExist();
}); });
test('it reveals the list manager when save is clicked', async function (assert) {
const place = {
title: 'Cool Cafe',
lat: 10,
lon: 10,
};
await render(<template><PlaceDetails @place={{place}} /></template>);
// Manager is initially hidden
assert.dom('.place-lists-manager').doesNotExist();
// Find the Save button
// It's the first button in .actions
const saveBtn = this.element.querySelector('.actions button');
await click(saveBtn);
// Manager should be visible now
assert.dom('.place-lists-manager').exists();
// Check for default lists from mock service
assert.dom('.place-lists-manager').includesText('Want to go');
assert.dom('.place-lists-manager').includesText('To do');
assert.dom('.place-lists-manager').includesText('Saved');
});
test('it handles saving a new place via master toggle', async function (assert) {
let storedPlace = null;
// Override mock service specifically for this test to spy on storePlace
class MockStorage extends Service {
lists = [];
async storePlace(place) {
storedPlace = place;
return { ...place, id: 'new-id', createdAt: new Date().toISOString() };
}
isPlaceSaved() {
return false;
}
findPlaceById() {
return null;
}
}
this.owner.register('service:storage', MockStorage);
const place = {
title: 'New Spot',
lat: 20,
lon: 20,
};
await render(<template><PlaceDetails @place={{place}} /></template>);
// Open manager
await click('.actions button');
// Find master "Saved" toggle
const masterToggle = this.element.querySelector(
'.place-lists-manager .master-toggle input'
);
// It should be unchecked initially for a new place
assert.dom(masterToggle).isNotChecked();
// Click it to save
await click(masterToggle);
// Verify storePlace was called
assert.ok(storedPlace, 'storePlace was called');
assert.strictEqual(storedPlace.title, 'New Spot');
});
test('it handles removing a saved place via master toggle', async function (assert) {
let removedPlaceId = null;
class MockStorage extends Service {
lists = [];
async removePlace(place) {
removedPlaceId = place.id;
}
isPlaceSaved() {
return true;
}
}
this.owner.register('service:storage', MockStorage);
const place = {
id: 'saved-id',
title: 'Saved Spot',
lat: 30,
lon: 30,
createdAt: '2023-01-01', // Marks it as saved
};
await render(<template><PlaceDetails @place={{place}} /></template>);
// Open manager
await click('.actions button');
// Find master "Saved" toggle
const masterToggle = this.element.querySelector(
'.place-lists-manager .master-toggle input'
);
// It should be checked initially for a saved place
assert.dom(masterToggle).isChecked();
// Click it to remove
await click(masterToggle);
assert.strictEqual(removedPlaceId, 'saved-id', 'removePlace was called');
assert.deepEqual(place._listIds, [], '_listIds was cleared on the object');
});
test('it adds place to a list', async function (assert) {
let listId = null;
let placeArg = null;
let shouldAdd = null;
class MockStorage extends Service {
lists = [{ id: 'favs', title: 'Favorites', color: 'red' }];
async togglePlaceList(place, id, add) {
placeArg = place;
listId = id;
shouldAdd = add;
}
isPlaceSaved() {
return true;
}
}
this.owner.register('service:storage', MockStorage);
// Provide a place that is already saved
const place = {
id: 'p1',
title: 'My Spot',
createdAt: '2023-01-01',
_listIds: [], // Not in any list yet
};
await render(<template><PlaceDetails @place={{place}} /></template>);
// Open manager
await click('.actions button');
// Find the checkbox for "Favorites"
const checkbox = this.element.querySelectorAll(
'.place-lists-manager input[type="checkbox"]'
)[1]; // Index 1 because 0 is master toggle
await click(checkbox);
assert.strictEqual(listId, 'favs');
assert.strictEqual(placeArg.id, 'p1');
assert.true(shouldAdd);
});
test('it respects storage service state over stale place object', async function (assert) {
class MockStorage extends Service {
lists = [];
isPlaceSaved() {
return false;
}
findPlaceById() {
return null;
}
}
this.owner.register('service:storage', MockStorage);
const place = {
id: 'stale-id',
title: 'Stale Place',
createdAt: '2023-01-01', // Looks saved
};
await render(<template><PlaceDetails @place={{place}} /></template>);
// Button should say "Save", not "Saved" because isPlaceSaved returns false
assert.dom('.actions button').hasText('Save');
assert.dom('.actions button').doesNotHaveClass('btn-secondary');
});
}); });

View File

@@ -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,"category":null,"lat":"52.5200","lon":"13.4050"}}', 'transitionTo: search {"queryParams":{"q":"berlin","selected":null,"lat":"52.5200","lon":"13.4050"}}',
]); ]);
}); });
@@ -134,96 +134,4 @@ 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"}}',
]);
});
}); });

View File

@@ -125,60 +125,4 @@ 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'
);
});
}); });

View File

@@ -1,68 +0,0 @@
import { module, test } from 'qunit';
import { setupTest } from 'marco/tests/helpers';
module('Unit | Service | map-ui', function (hooks) {
setupTest(hooks);
test('it handles loading state correctly', function (assert) {
let service = this.owner.lookup('service:map-ui');
// Initial state
assert.strictEqual(
service.loadingState,
null,
'loadingState starts as null'
);
// Start loading search A
service.startLoading('search', 'A');
assert.deepEqual(
service.loadingState,
{ type: 'search', value: 'A' },
'loadingState is set to search A'
);
// Stop loading search A (successful case)
service.stopLoading('search', 'A');
assert.strictEqual(
service.loadingState,
null,
'loadingState is cleared when stopped with matching parameters'
);
});
test('it handles race condition: stopLoading only clears if parameters match', function (assert) {
let service = this.owner.lookup('service:map-ui');
// 1. Start loading search A
service.startLoading('search', 'A');
assert.deepEqual(service.loadingState, { type: 'search', value: 'A' });
// 2. Start loading search B (interruption)
// In a real app, search B would start before search A finishes.
service.startLoading('search', 'B');
assert.deepEqual(
service.loadingState,
{ type: 'search', value: 'B' },
'loadingState updates to search B'
);
// 3. Search A finishes and tries to stop loading
// The service should ignore this because current loading state is for B
service.stopLoading('search', 'A');
assert.deepEqual(
service.loadingState,
{ type: 'search', value: 'B' },
'loadingState remains search B even after stopping search A'
);
// 4. Search B finishes
service.stopLoading('search', 'B');
assert.strictEqual(
service.loadingState,
null,
'loadingState is cleared when search B stops'
);
});
});

View File

@@ -251,45 +251,4 @@ 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'
);
});
}); });

View File

@@ -1,202 +0,0 @@
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'
);
});
});

View File

@@ -1,39 +0,0 @@
import { getIconNameForTags } from 'marco/utils/osm-icons';
import { module, test } from 'qunit';
module('Unit | Utility | osm-icons', function () {
test('it returns molar-tooth for amenity=dentist', function (assert) {
let result = getIconNameForTags({ amenity: 'dentist' });
assert.strictEqual(result, 'molar-tooth');
});
test('it returns molar-tooth for healthcare=dentist', function (assert) {
let result = getIconNameForTags({ healthcare: 'dentist' });
assert.strictEqual(result, 'molar-tooth');
});
test('it returns greek-cross for healthcare=hospital (catch-all)', function (assert) {
let result = getIconNameForTags({ healthcare: 'hospital' });
assert.strictEqual(result, 'greek-cross');
});
test('it returns greek-cross for healthcare=yes (catch-all)', function (assert) {
let result = getIconNameForTags({ healthcare: 'yes' });
assert.strictEqual(result, 'greek-cross');
});
test('it returns shopping-basket for known shop types like convenience', function (assert) {
let result = getIconNameForTags({ shop: 'convenience' });
assert.strictEqual(result, 'shopping-basket');
});
test('it returns shopping-bag for unknown shop types (catch-all)', function (assert) {
let result = getIconNameForTags({ shop: 'unknown_shop_type' });
assert.strictEqual(result, 'shopping-bag');
});
test('it returns null for unknown tags', function (assert) {
let result = getIconNameForTags({ foo: 'bar' });
assert.strictEqual(result, null);
});
});

View File

@@ -1,58 +0,0 @@
import { mapToStorageSchema } from 'marco/utils/place-mapping';
import { module, test } from 'qunit';
module('Unit | Utility | place-mapping', function () {
test('it maps a raw place object to the storage schema', function (assert) {
const rawPlace = {
osmId: 12345,
osmType: 'node',
lat: 52.52,
lon: 13.405,
osmTags: {
name: 'Test Place',
website: 'https://example.com',
},
description: 'A test description',
};
const result = mapToStorageSchema(rawPlace);
assert.strictEqual(result.title, 'Test Place');
assert.strictEqual(result.lat, 52.52);
assert.strictEqual(result.lon, 13.405);
assert.strictEqual(result.osmId, '12345');
assert.strictEqual(result.osmType, 'node');
assert.strictEqual(result.url, 'https://example.com');
assert.strictEqual(result.description, 'A test description');
assert.deepEqual(result.osmTags, rawPlace.osmTags);
assert.deepEqual(result.tags, []);
});
test('it prioritizes place.title over osmTags.name', function (assert) {
const rawPlace = {
osmId: 123,
lat: 0,
lon: 0,
title: 'Custom Title',
osmTags: {
name: 'OSM Name',
},
};
const result = mapToStorageSchema(rawPlace);
assert.strictEqual(result.title, 'Custom Title');
});
test('it handles fallback title correctly when no name is present', function (assert) {
const rawPlace = {
id: 987,
lat: 10,
lon: 20,
osmTags: {},
};
const result = mapToStorageSchema(rawPlace);
assert.strictEqual(result.title, 'Untitled Place');
assert.strictEqual(result.osmId, '987');
});
});

View File

@@ -4,7 +4,7 @@ import { babel } from '@rollup/plugin-babel';
export default defineConfig({ export default defineConfig({
// server: { // server: {
// host: '0.0.0.0', // host: '0.0.0.0'
// }, // },
plugins: [ plugins: [
ember(), ember(),