49 Commits

Author SHA1 Message Date
55aecbd699 Apply same button-press effect to both header buttons 2026-02-10 18:51:26 +04:00
ccaa56b78f Remove remaining default tap highlights on mobiles 2026-02-10 18:44:41 +04:00
d30375707a Prevent map search when zoomed out too much
It's usually an accidental click, and if not, the search radius/pulse
wouldn't be clearly visible.
2026-02-10 18:33:44 +04:00
53300b92f5 Re-add zoom controls 2026-02-10 17:47:03 +04:00
c37f794eea Auto-locate user on first app launch
closes #17
2026-02-10 17:18:59 +04:00
4bc92bb7cc Run tests before versioning 2026-02-08 17:01:56 +04:00
9f48d7b264 1.11.2 2026-02-08 17:01:01 +04:00
bbd3bf47c6 Merge pull request 'Fix back button behavior' (#14) from bugfix/back_button into master
Reviewed-on: #14
2026-02-08 13:00:07 +00:00
59e3d91071 Fix back button behavior
fixes #12
2026-02-08 16:59:53 +04:00
348b721876 1.11.1 2026-01-27 15:05:08 +07:00
3d982a6a7c More kinetic panning optimizations 2026-01-27 15:04:25 +07:00
0af9d9f16d 1.11.0 2026-01-27 14:24:52 +07:00
a0f132ec64 Disable kinetic panning on mobile by default, add setting for it 2026-01-27 14:23:43 +07:00
925f26ae5d Update status doc 2026-01-27 14:08:27 +07:00
58bb8831f3 Prevent autofocus on mobile
Makes it difficult to fine-tune the location first
2026-01-27 14:06:26 +07:00
585837cae7 1.10.1 2026-01-27 13:47:09 +07:00
42c5282844 Don't show GMaps link for private bookmarks 2026-01-27 13:46:43 +07:00
8a0603c65e 1.10.0 2026-01-27 13:38:33 +07:00
8e3187f38d Improve place-create button 2026-01-27 13:37:59 +07:00
a73e5cda6a Clean up code comments 2026-01-27 13:25:54 +07:00
0212fa359b Change console statements to debug or warn 2026-01-27 12:58:36 +07:00
8c58a76030 Create new places
And find them in search
2026-01-27 12:58:23 +07:00
a10f87290a Update status doc 2026-01-27 11:32:21 +07:00
e7b3b72e2f 1.9.0 2026-01-27 11:22:37 +07:00
399ad1822d Humanize place type properly, refactor for other tags 2026-01-27 11:21:51 +07:00
104a742543 Use dark grey for all text, change theme color 2026-01-27 11:00:06 +07:00
a8dc4c81e4 Implement simple query cache for Overpass/OSM search
So when we return to the search route, we don't have to refetch
2026-01-27 09:50:41 +07:00
156280950f Refactor search results with dedicated route 2026-01-27 09:50:26 +07:00
41d61be42e 1.8.10 2026-01-27 08:55:23 +07:00
06b47d96a7 Fix search results scrolling behavior 2026-01-27 08:54:42 +07:00
e8af959be6 Improve search results layout/styling 2026-01-27 08:54:38 +07:00
254e177cbf Update README 2026-01-26 19:55:18 +07:00
47fbc8e7cf Use published places module 2026-01-26 18:12:29 +07:00
4ad0df22e2 1.8.9 2026-01-26 17:53:09 +07:00
0decb4cf1b Optimize animations on iOS 2026-01-26 17:52:41 +07:00
2193f935cc Change default center and zoom to show the world on desktop 2026-01-26 17:52:14 +07:00
b2b03c0a38 1.8.8 2026-01-26 17:20:49 +07:00
0be02c5b20 Update status doc 2026-01-26 17:06:39 +07:00
653e44348c Fix auto-zoom when focussing form field on iOS 2026-01-26 17:01:29 +07:00
8fdc697a17 1.8.7 2026-01-26 16:46:34 +07:00
d9b2a17b91 Noto serif or no serif 2026-01-26 16:46:07 +07:00
85255318ba 1.8.6 2026-01-26 16:32:43 +07:00
713d9d53e6 Styling optimizations 2026-01-26 16:32:16 +07:00
e0ea0ca988 Prevent mobile Safari from resizing text 2026-01-26 16:23:46 +07:00
3cc2a2649a 1.8.5 2026-01-26 16:17:53 +07:00
924484a191 Set base font size explicitly 2026-01-26 16:16:47 +07:00
b960ba0868 Unify button styles, improve sizing 2026-01-26 16:15:52 +07:00
3b22f8c2f4 1.8.4 2026-01-26 16:03:06 +07:00
1a643e980d Fix location of additional map controls 2026-01-26 16:02:23 +07:00
38 changed files with 1114 additions and 370 deletions

View File

@@ -1,6 +1,6 @@
# Project Status: Marco # Project Status: Marco
**Last Updated:** Sat Jan 24 2026 **Last Updated:** Tue Jan 27 2026
## Project Context ## Project Context
@@ -15,15 +15,19 @@ We are building **Marco**, a decentralized maps application using **Ember.js** (
- Implemented a hybrid click handler: - Implemented a hybrid click handler:
- Detects clicks on visual vector tiles. - Detects clicks on visual vector tiles.
- Falls back to fetching authoritative data from an **Overpass API** service. - Falls back to fetching authoritative data from an **Overpass API** service.
- Uses a **heuristic** (distance + type matching) to link visual clicks to API results (handling data desynchronization).
- **Logic Upgrade:** Map intelligently detects if _any_ sidebar/pane is open and handles outside clicks to close them instead of initiating new searches. - **Logic Upgrade:** Map intelligently detects if _any_ sidebar/pane is open and handles outside clicks to close them instead of initiating new searches.
- **Optimization:** Added **10px hit tolerance** for easier tapping on mobile devices. - **Optimization:** Added **10px hit tolerance** for easier tapping on mobile devices.
- **Visuals:** Increased bookmark marker size (Radius 9px) and added a subtle drop shadow. - **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). - **Feedback:** Implemented a "pulse" animation (via OpenLayers Overlay) at the click location to visualize the search radius (30m/50m).
- **Mobile UX:** - **Mobile UX:**
- Disabled browser tap highlights (`-webkit-tap-highlight-color: transparent`) to prevent blue flashing on Android. - **Touch:** Disabled browser tap highlights (`-webkit-tap-highlight-color: transparent`) to prevent blue flashing on Android.
- Disabled "pull-to-refresh" (`overscroll-behavior: none`) on the body to prevent accidental reloads while keeping the sidebar scrollable (`contain`). - **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. - **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"):** - **Geolocation ("Locate Me"):**
- Implemented a "Locate Me" button with robust tracking logic. - 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). - **Dynamic Zoom:** Automatically zooms to a level where the accuracy circle covers ~10% of the map (fallback logic handles missing accuracy data).
@@ -44,7 +48,7 @@ We are building **Marco**, a decentralized maps application using **Ember.js** (
- configured with `maxAge: false` to ensure data freshness. - configured with `maxAge: false` to ensure data freshness.
- **Dependencies:** Uses `ulid` and `latlon-geohash` internally. - **Dependencies:** Uses `ulid` and `latlon-geohash` internally.
### 3. App Infrastructure ### 3. App Infrastructure & Build
- **Services:** - **Services:**
- `storage.js`: Initializes RemoteStorage, claims access, enables caching, and sets up the widget. Consumes the new `getPlaces` API. - `storage.js`: Initializes RemoteStorage, claims access, enables caching, and sets up the widget. Consumes the new `getPlaces` API.
@@ -53,6 +57,7 @@ We are building **Marco**, a decentralized maps application using **Ember.js** (
- `osm.js`: Fetches nearby POIs from Overpass API. - `osm.js`: Fetches nearby POIs from Overpass API.
- **Configurable:** Now supports dynamic API endpoints via `SettingsService`. - **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. - **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`. - `settings.js`: Manages user preferences (currently Overpass API provider) persisted to `localStorage`.
- **UI Components:** - **UI Components:**
- `places-sidebar.gjs`: Displays a list of nearby POIs. - `places-sidebar.gjs`: Displays a list of nearby POIs.
@@ -68,39 +73,62 @@ We are building **Marco**, a decentralized maps application using **Ember.js** (
- **Geo Utils:** - **Geo Utils:**
- `app/utils/geo.js`: Haversine distance calculations. - `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. - `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".
- **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 & Data Optimization ### 4. Routing & Architecture (Refactored)
- **Explicit URLs:** Implemented routing support for specific OSM entities via `/place/osm:node:<id>` and `/place/osm:way:<id>`, distinguishing them from local bookmarks (ULIDs). - **URL-Driven Architecture:** Moved from service-based state to proper route-based state management.
- `/search?lat=...&lon=...&q=...`: Displays search results list.
- `/place/:place_id`: Displays details for a specific place (OSM POI or Bookmark).
- **Heuristic Navigation:** The `search` route implements "visual click matching" logic. If a search yields a direct match (exact name or very close proximity), it automatically redirects to the `/place/` route, skipping the list view.
- **Back Button Support:** Browser history works correctly. Navigating "Back" from a place returns to the cached search results instantly without network requests.
- **Explicit URLs:** Routes support specific OSM entities via `/place/osm:node:<id>` and `/place/osm:way:<id>`, distinguishing them from local bookmarks (ULIDs).
- **Smart Linking:** The `showPlaces` action intercepts search results and automatically resolves them to existing **Bookmarks** if a match is found (via `storage.findPlaceById`). This ensures the app navigates to the persistent Bookmark URL (ULID) and correctly reflects the "Saved" status in the UI instead of treating it as a new generic OSM place. - **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. - **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.
- **Performance:** Optimized navigation to prevent redundant network requests. Clicking a map pin passes the existing data object to the route, skipping the `model` hook (no re-fetch) while maintaining correct deep-linkable URLs via a custom `serialize` hook in `PlaceRoute`.
### 5. Creation & Editing Workflow
- **Create Place:**
- Implemented `/place/new` route for creating new private places.
- **UX:** Map displays a central crosshair for precise location selection.
- **Mobile Optimization:**
- Disabled map inertia (`kinetic: false`) to ensure the map stops exactly where the finger releases.
- `PlaceEditForm` conditionally disables autofocus on mobile screens (`<= 768px`) to prevent the onscreen keyboard from obscuring the map view immediately.
- Responsive crosshair sizing (48px desktop / 24px mobile).
- **Persistence:** Form data (Title, Description) and Map coordinates are securely saved to RemoteStorage via `storage.storePlace`.
## Current State ## Current State
- **Repo:** The app runs via `pnpm start`. - **Repo:** The app runs via `pnpm start`.
- **Workflow:** - **Workflow:**
1. User pans map -> `moveend` triggers `storage.loadPlacesInBounds`. 1. User pans map -> `moveend` triggers `storage.loadPlacesInBounds`.
2. User clicks map -> "Pulse" animation -> hybrid hit detection (Visual Tile vs Overpass). 2. User clicks map -> Route transition to `/search` -> "Pulse" animation -> hybrid hit detection (Visual Tile vs Overpass).
3. **Navigation:** Selected place is checked against bookmarks; if found, it uses the Bookmark object. Otherwise, it uses the OSM object. 3. **Navigation:**
- If direct match: Redirect to `/place/:id`.
- If multiple results: Show `/search` list view.
4. Sidebar displays details via `<PlaceDetails>` component (Bottom sheet on mobile). 4. Sidebar displays details via `<PlaceDetails>` component (Bottom sheet on mobile).
5. User clicks "Save Bookmark" -> Stores JSON in RemoteStorage. 5. **Creation:** User clicks "Create Place" -> Enters creation mode (crosshair) -> Positions map -> Enters details -> Save.
6. RemoteStorage change event -> Debounced reload updates the map reactive-ly. 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. 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. 8. **Settings:** User can change the Overpass API provider via the new Settings menu.
## Files Currently in Focus ## Files Currently in Focus
- `app/templates/application.gjs`: Core layout and "Outside Click" logic. - `app/components/map.gjs`
- `app/components/settings-pane.gjs`: Settings UI. - `app/components/place-edit-form.gjs`
- `app/services/settings.js`: Settings persistence. - `app/templates/place/new.gjs`
## Next Steps & Pending Tasks ## Next Steps & Pending Tasks
1. **Collections/Lists:** Implement ability to organize bookmarks into lists/collections. 1. **Linting & Code Quality:** Fix remaining CSS errors and address unused variables/runloop usage.
2. **Linting & Code Quality:** Fix remaining CSS errors, remove inline styles in `map.gjs`, 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). 3. **Performance:** Monitor performance with large datasets (thousands of bookmarks).
4. **Testing:** Add automated tests for the geohash coverage, retry logic, and new editing features.
## Technical Constraints ## Technical Constraints

View File

@@ -73,6 +73,7 @@ To run the script, you need `imagemagick` and `librsvg` installed:
- [ember.js](https://emberjs.com/) - [ember.js](https://emberjs.com/)
- [remoteStorage.js](https://remotestorage.io/rs.js/docs/) - [remoteStorage.js](https://remotestorage.io/rs.js/docs/)
- [@remotestorage/module-places](https://gitea.kosmos.org/raucao/remotestorage-module-places)
- [Vite](https://vite.dev) - [Vite](https://vite.dev)
- Development Browser Extensions - Development Browser Extensions
- [ember inspector for chrome](https://chrome.google.com/webstore/detail/ember-inspector/bmdblncegkenkacieihfhpjfppoconhi) - [ember inspector for chrome](https://chrome.google.com/webstore/detail/ember-inspector/bmdblncegkenkacieihfhpjfppoconhi)

View File

@@ -24,7 +24,7 @@ export default class AppHeaderComponent extends Component {
<header class="app-header"> <header class="app-header">
<div class="header-left"> <div class="header-left">
<button <button
class="icon-btn" class="menu-btn btn-press"
type="button" type="button"
aria-label="Menu" aria-label="Menu"
{{on "click" @onToggleMenu}} {{on "click" @onToggleMenu}}
@@ -36,7 +36,7 @@ export default class AppHeaderComponent extends Component {
<div class="header-right"> <div class="header-right">
<div class="user-menu-container"> <div class="user-menu-container">
<button <button
class="user-btn" class="user-btn btn-press"
type="button" type="button"
aria-label="User Menu" aria-label="User Menu"
{{on "click" this.toggleUserMenu}} {{on "click" this.toggleUserMenu}}

View File

@@ -15,8 +15,10 @@ import mapPin from 'feather-icons/dist/icons/map-pin.svg?raw';
import menu from 'feather-icons/dist/icons/menu.svg?raw'; import menu from 'feather-icons/dist/icons/menu.svg?raw';
import navigation from 'feather-icons/dist/icons/navigation.svg?raw'; import navigation from 'feather-icons/dist/icons/navigation.svg?raw';
import phone from 'feather-icons/dist/icons/phone.svg?raw'; import phone from 'feather-icons/dist/icons/phone.svg?raw';
import plus from 'feather-icons/dist/icons/plus.svg?raw';
import server from 'feather-icons/dist/icons/server.svg?raw'; import server from 'feather-icons/dist/icons/server.svg?raw';
import settings from 'feather-icons/dist/icons/settings.svg?raw'; import settings from 'feather-icons/dist/icons/settings.svg?raw';
import target from 'feather-icons/dist/icons/target.svg?raw';
import user from 'feather-icons/dist/icons/user.svg?raw'; import user from 'feather-icons/dist/icons/user.svg?raw';
import x from 'feather-icons/dist/icons/x.svg?raw'; import x from 'feather-icons/dist/icons/x.svg?raw';
import zap from 'feather-icons/dist/icons/zap.svg?raw'; import zap from 'feather-icons/dist/icons/zap.svg?raw';
@@ -36,8 +38,10 @@ const ICONS = {
menu, menu,
navigation, navigation,
phone, phone,
plus,
server, server,
settings, settings,
target,
user, user,
x, x,
zap, zap,

View File

@@ -4,6 +4,8 @@ import { modifier } from 'ember-modifier';
import 'ol/ol.css'; import 'ol/ol.css';
import Map 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 Kinetic from 'ol/Kinetic.js';
import View from 'ol/View.js'; import View from 'ol/View.js';
import { fromLonLat, toLonLat, getPointResolution } from 'ol/proj.js'; import { fromLonLat, toLonLat, getPointResolution } from 'ol/proj.js';
import Overlay from 'ol/Overlay.js'; import Overlay from 'ol/Overlay.js';
@@ -15,12 +17,13 @@ import Point from 'ol/geom/Point.js';
import Geolocation from 'ol/Geolocation.js'; import Geolocation from 'ol/Geolocation.js';
import { Style, Circle, Fill, Stroke } from 'ol/style.js'; import { Style, Circle, Fill, Stroke } from 'ol/style.js';
import { apply } from 'ol-mapbox-style'; import { apply } from 'ol-mapbox-style';
import { getDistance } from '../utils/geo';
export default class MapComponent extends Component { export default class MapComponent extends Component {
@service osm; @service osm;
@service storage; @service storage;
@service mapUi; @service mapUi;
@service router;
@service settings;
mapInstance; mapInstance;
bookmarkSource; bookmarkSource;
@@ -28,6 +31,8 @@ export default class MapComponent extends Component {
searchOverlayElement; searchOverlayElement;
selectedPinOverlay; selectedPinOverlay;
selectedPinElement; selectedPinElement;
crosshairElement;
crosshairOverlay;
setupMap = modifier((element) => { setupMap = modifier((element) => {
if (this.mapInstance) return; if (this.mapInstance) return;
@@ -61,8 +66,9 @@ export default class MapComponent extends Component {
}); });
// Default view settings // Default view settings
let center = [99.05738, 7.55087]; let center = [14.21683569, 27.060114248];
let zoom = 13.0; let zoom = 2.661;
let restoredFromStorage = false;
// Try to restore from localStorage // Try to restore from localStorage
try { try {
@@ -77,6 +83,7 @@ export default class MapComponent extends Component {
) { ) {
center = parsed.center; center = parsed.center;
zoom = parsed.zoom; zoom = parsed.zoom;
restoredFromStorage = true;
} }
} }
} catch (e) { } catch (e) {
@@ -94,10 +101,13 @@ export default class MapComponent extends Component {
layers: [openfreemap, bookmarkLayer], layers: [openfreemap, bookmarkLayer],
view: view, view: view,
controls: defaultControls({ controls: defaultControls({
zoom: false, zoom: true,
rotate: true, rotate: true,
attribution: true, attribution: true,
}), }),
interactions: defaultInteractions({
dragPan: false, // Disable default DragPan to add a custom one
}),
}); });
apply(this.mapInstance, 'https://tiles.openfreemap.org/styles/liberty'); apply(this.mapInstance, 'https://tiles.openfreemap.org/styles/liberty');
@@ -112,18 +122,13 @@ export default class MapComponent extends Component {
this.mapInstance.addOverlay(this.searchOverlay); this.mapInstance.addOverlay(this.searchOverlay);
// Selected Pin Overlay (Red Marker) // Selected Pin Overlay (Red Marker)
// We create the element in the template (or JS) and attach it.
// Using JS creation to ensure it's cleanly managed by OpenLayers
this.selectedPinElement = document.createElement('div'); this.selectedPinElement = document.createElement('div');
this.selectedPinElement.className = 'selected-pin-container'; this.selectedPinElement.className = 'selected-pin-container';
// Create the icon structure inside // Create the icon structure inside
const pinIcon = document.createElement('div'); const pinIcon = document.createElement('div');
pinIcon.className = 'selected-pin'; pinIcon.className = 'selected-pin';
// We can't use the Glimmer <Icon> component easily inside a raw DOM element created here. // Simple SVG for Map Pin
// So we'll inject the SVG string directly or mount it.
// Feather icons are globally available if we used the script, but we are using the module approach.
// Simple SVG for Map Pin:
pinIcon.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"></path><circle cx="12" cy="10" r="3" style="fill: #b31412; stroke: none;"></circle></svg>`; pinIcon.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"></path><circle cx="12" cy="10" r="3" style="fill: #b31412; stroke: none;"></circle></svg>`;
const pinShadow = document.createElement('div'); const pinShadow = document.createElement('div');
@@ -134,11 +139,25 @@ export default class MapComponent extends Component {
this.selectedPinOverlay = new Overlay({ this.selectedPinOverlay = new Overlay({
element: this.selectedPinElement, element: this.selectedPinElement,
positioning: 'bottom-center', // Important: Pin tip is at the bottom positioning: 'bottom-center', // Pin tip is at the bottom
stopEvent: false, // Let clicks pass through stopEvent: false, // Let clicks pass through
}); });
this.mapInstance.addOverlay(this.selectedPinOverlay); this.mapInstance.addOverlay(this.selectedPinOverlay);
// Crosshair Overlay (for Creating New Place)
this.crosshairElement = document.createElement('div');
this.crosshairElement.className = 'map-crosshair';
this.crosshairElement.innerHTML = `
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="12" y1="5" x2="12" y2="19"></line>
<line x1="5" y1="12" x2="19" y2="12"></line>
</svg>
`;
element.appendChild(this.crosshairElement);
// Geolocation Pulse Overlay // Geolocation Pulse Overlay
this.locationOverlayElement = document.createElement('div'); this.locationOverlayElement = document.createElement('div');
this.locationOverlayElement.className = 'search-pulse blue'; this.locationOverlayElement.className = 'search-pulse blue';
@@ -226,6 +245,7 @@ export default class MapComponent extends Component {
const coordinates = geolocation.getPosition(); const coordinates = geolocation.getPosition();
const accuracyGeometry = geolocation.getAccuracyGeometry(); const accuracyGeometry = geolocation.getAccuracyGeometry();
const accuracy = geolocation.getAccuracy(); const accuracy = geolocation.getAccuracy();
console.debug('Geolocation change:', { coordinates, accuracy });
if (!coordinates) return; if (!coordinates) return;
@@ -281,7 +301,7 @@ export default class MapComponent extends Component {
}; };
if (targetResolution) { if (targetResolution) {
const maxResolution = view.getResolutionForZoom(17); // Use 17 as safe max zoom for accuracy < 20m const maxResolution = view.getResolutionForZoom(17);
viewOptions.resolution = Math.max(targetResolution, maxResolution); viewOptions.resolution = Math.max(targetResolution, maxResolution);
} else { } else {
viewOptions.zoom = 16; viewOptions.zoom = 16;
@@ -290,7 +310,8 @@ export default class MapComponent extends Component {
this.mapInstance.getView().animate(viewOptions); this.mapInstance.getView().animate(viewOptions);
}; };
locateBtn.addEventListener('click', () => { const startLocating = () => {
console.debug('Getting current geolocation...')
// 1. Clear any previous session // 1. Clear any previous session
stopLocating(); stopLocating();
@@ -314,7 +335,9 @@ export default class MapComponent extends Component {
locateTimeout = setTimeout(() => { locateTimeout = setTimeout(() => {
stopLocating(); stopLocating();
}, 10000); }, 10000);
}); };
locateBtn.addEventListener('click', startLocating);
const locateControl = new Control({ const locateControl = new Control({
element: locateElement, element: locateElement,
@@ -323,6 +346,11 @@ export default class MapComponent extends Component {
this.mapInstance.addLayer(geolocationLayer); this.mapInstance.addLayer(geolocationLayer);
this.mapInstance.addControl(locateControl); this.mapInstance.addControl(locateControl);
// Auto-locate on first visit (if not restored from storage and on home page)
if (!restoredFromStorage && this.router.currentRouteName === 'index') {
startLocating();
}
this.mapInstance.on('singleclick', this.handleMapClick); this.mapInstance.on('singleclick', this.handleMapClick);
// Load places when map moves // Load places when map moves
@@ -337,16 +365,41 @@ export default class MapComponent extends Component {
this.mapInstance.getTarget().style.cursor = hit ? 'pointer' : ''; this.mapInstance.getTarget().style.cursor = hit ? 'pointer' : '';
}); });
// Load initial bookmarks
this.storage.rs.on('ready', () => { this.storage.rs.on('ready', () => {
// Initial load based on current view
this.handleMapMove(); this.handleMapMove();
}); });
});
// Listen for remote storage changes updateInteractions = modifier(() => {
// this.storage.rs.on('connected', () => { if (!this.mapInstance) return;
// this.loadBookmarks();
// }); // Remove existing DragPan interactions
this.mapInstance.getInteractions().getArray().slice().forEach((interaction) => {
if (interaction instanceof DragPan) {
this.mapInstance.removeInteraction(interaction);
}
});
// Add new DragPan with current setting
const kinetic = this.settings.mapKinetic
? new Kinetic(-0.005, 0.05, 100)
: false;
// Fix for "sticky" touches on mobile:
// If we're on mobile (width <= 768) AND using kinetic,
// we increase the minimum velocity required to trigger kinetic panning.
// This prevents slow drags from being interpreted as a "throw"
if (this.settings.mapKinetic && window.innerWidth <= 768) {
// Default minVelocity is 0.05. We bump it up significantly.
// This means the user has to really "flick" the map to get inertia.
kinetic.minVelocity_ = 0.25;
}
this.mapInstance.addInteraction(
new DragPan({
kinetic: kinetic,
})
);
}); });
// Track the selected place from the UI Service (Router -> Map) // Track the selected place from the UI Service (Router -> Map)
@@ -415,12 +468,9 @@ export default class MapComponent extends Component {
const offsetPixels = height * 0.25; // Distance from desired pin pos to map center const offsetPixels = height * 0.25; // Distance from desired pin pos to map center
const offsetMapUnits = offsetPixels * resolution; const offsetMapUnits = offsetPixels * resolution;
// Shift center SOUTH (decrease Y) // Shift center SOUTH (decrease Y).
// Note: In Web Mercator (EPSG:3857), Y increases North. // Note: In Web Mercator (EPSG:3857), Y increases North.
// So to look "lower", we decrease Y? No wait. // To move the camera South (Lower Y), we subtract.
// If we move the camera South (decrease Y), the features move North (Up) on screen.
// We want the Pin (fixed lat/lon) to be Higher up on screen.
// So we must move the Camera South (Lower Y).
targetCenter = [coords[0], coords[1] - offsetMapUnits]; targetCenter = [coords[0], coords[1] - offsetMapUnits];
} }
@@ -468,7 +518,6 @@ export default class MapComponent extends Component {
} }
} }
// Re-fetch bookmarks when the version changes (triggered by parent action or service)
updateBookmarks = modifier(() => { updateBookmarks = modifier(() => {
// Depend on the tracked storage.placesInView to automatically update when they change // Depend on the tracked storage.placesInView to automatically update when they change
const places = this.storage.placesInView; const places = this.storage.placesInView;
@@ -480,15 +529,10 @@ export default class MapComponent extends Component {
if (!this.bookmarkSource) return; if (!this.bookmarkSource) return;
if (!places || places.length === 0) { if (!places || places.length === 0) {
// Fallback or explicit check if we have tracked property usage?
// The service updates 'placesInView'. We should probably use that if we want reactiveness.
places = this.storage.placesInView; places = this.storage.placesInView;
} }
// Previously: const places = await this.storage.places.getPlaces();
// We no longer want to fetch everything blindly.
// We rely on 'placesInView' being updated by handleMapMove calling storage.loadPlacesInBounds. // We rely on 'placesInView' being updated by handleMapMove calling storage.loadPlacesInBounds.
this.bookmarkSource.clear(); this.bookmarkSource.clear();
if (places && Array.isArray(places)) { if (places && Array.isArray(places)) {
@@ -510,9 +554,120 @@ export default class MapComponent extends Component {
} }
} }
// Sync the pulse animation with the UI service state
syncPulse = modifier(() => {
if (!this.searchOverlayElement) return;
if (this.mapUi.isSearching) {
this.searchOverlayElement.classList.add('active');
} else {
this.searchOverlayElement.classList.remove('active');
}
});
// Sync the creation mode (Crosshair)
syncCreationMode = modifier(() => {
if (!this.crosshairElement || !this.mapInstance) return;
if (this.mapUi.isCreating) {
this.crosshairElement.classList.add('visible');
// If we have initial coordinates from the route (e.g. reload or link),
// we need to pan the map so those coordinates are UNDER the crosshair.
const coords = this.mapUi.creationCoordinates;
if (coords && coords.lat && coords.lon) {
// We only animate if the map center isn't already "roughly" correct.
const targetCoords = fromLonLat([coords.lon, coords.lat]);
this.animateToCrosshair(targetCoords);
}
} else {
this.crosshairElement.classList.remove('visible');
}
});
animateToCrosshair(targetCoords) {
if (!this.mapInstance || !this.crosshairElement) return;
// 1. Get current visual position of the crosshair
const mapRect = this.mapInstance.getTargetElement().getBoundingClientRect();
const crosshairRect = this.crosshairElement.getBoundingClientRect();
const crosshairPixelX =
crosshairRect.left + crosshairRect.width / 2 - mapRect.left;
const crosshairPixelY =
crosshairRect.top + crosshairRect.height / 2 - mapRect.top;
// 2. Get the center pixel of the map viewport
const size = this.mapInstance.getSize();
const mapCenterX = size[0] / 2;
const mapCenterY = size[1] / 2;
// 3. Calculate the offset (how far the crosshair is from the geometric center)
const offsetX = crosshairPixelX - mapCenterX;
const offsetY = crosshairPixelY - mapCenterY;
// 4. Calculate the new map center
// We want 'targetCoords' to be at [crosshairPixelX, crosshairPixelY].
// If we center the map on 'targetCoords', it will be at [mapCenterX, mapCenterY].
// So we need to shift the map center by the OPPOSITE of the offset.
const view = this.mapInstance.getView();
const resolution = view.getResolution();
const offsetMapUnitsX = offsetX * resolution;
const offsetMapUnitsY = -offsetY * resolution; // Y is inverted in pixel vs map coords
const targetX = targetCoords[0];
const targetY = targetCoords[1];
const newCenterX = targetX - offsetMapUnitsX;
const newCenterY = targetY - offsetMapUnitsY;
// Only animate if the difference is significant (avoid micro-jitters/loops)
const currentCenter = view.getCenter();
const dist = Math.sqrt(
Math.pow(currentCenter[0] - newCenterX, 2) +
Math.pow(currentCenter[1] - newCenterY, 2)
);
// 1 meter is approx 1 unit in Mercator near equator, varies by latitude.
// Resolution at zoom 18 is approx 0.6m/pixel.
// Let's use a small pixel threshold.
if (dist > resolution * 5) {
view.animate({
center: [newCenterX, newCenterY],
duration: 800,
easing: (t) => t * (2 - t), // Ease-out
});
}
}
handleMapMove = async () => { handleMapMove = async () => {
if (!this.mapInstance) return; if (!this.mapInstance) return;
// If in creation mode, update the coordinates in the service AND the URL
if (this.mapUi.isCreating) {
// Calculate coordinates under the crosshair element
// We need the pixel position of the crosshair relative to the map viewport
// The crosshair is positioned via CSS, so we can use getBoundingClientRect
const mapRect = this.mapInstance.getTargetElement().getBoundingClientRect();
const crosshairRect = this.crosshairElement.getBoundingClientRect();
const centerX = crosshairRect.left + crosshairRect.width / 2 - mapRect.left;
const centerY = crosshairRect.top + crosshairRect.height / 2 - mapRect.top;
const coordinate = this.mapInstance.getCoordinateFromPixel([centerX, centerY]);
const center = toLonLat(coordinate);
const lat = parseFloat(center[1].toFixed(6));
const lon = parseFloat(center[0].toFixed(6));
this.mapUi.updateCreationCoordinates(lat, lon);
// Update URL without triggering a full refresh
// We use replaceWith to avoid cluttering history
this.router.replaceWith('place.new', { queryParams: { lat, lon } });
}
const size = this.mapInstance.getSize(); const size = this.mapInstance.getSize();
const extent = this.mapInstance.getView().calculateExtent(size); const extent = this.mapInstance.getView().calculateExtent(size);
const [minLon, minLat] = toLonLat([extent[0], extent[1]]); const [minLon, minLat] = toLonLat([extent[0], extent[1]]);
@@ -546,7 +701,6 @@ export default class MapComponent extends Component {
}); });
let clickedBookmark = null; let clickedBookmark = null;
let selectedFeatureName = null; let selectedFeatureName = null;
let selectedFeatureType = null;
if (features && features.length > 0) { if (features && features.length > 0) {
console.debug(`Found ${features.length} features in map layer:`); console.debug(`Found ${features.length} features in map layer:`);
@@ -561,7 +715,6 @@ export default class MapComponent extends Component {
const props = features[0].getProperties(); const props = features[0].getProperties();
if (props.name) { if (props.name) {
selectedFeatureName = props.name; selectedFeatureName = props.name;
selectedFeatureType = props.class || props.subclass;
} }
} }
@@ -569,13 +722,11 @@ export default class MapComponent extends Component {
if (this.args.isSidebarOpen) { if (this.args.isSidebarOpen) {
// If it's a bookmark, we allow "switching" to it even if sidebar is open // If it's a bookmark, we allow "switching" to it even if sidebar is open
if (clickedBookmark) { if (clickedBookmark) {
console.log( console.debug(
'Clicked bookmark while sidebar open (switching):', 'Clicked bookmark while sidebar open (switching):',
clickedBookmark clickedBookmark
); );
if (this.args.onPlacesFound) { this.router.transitionTo('place', clickedBookmark);
this.args.onPlacesFound([], clickedBookmark);
}
return; return;
} }
@@ -588,10 +739,15 @@ export default class MapComponent extends Component {
// Normal behavior (sidebar is closed) // Normal behavior (sidebar is closed)
if (clickedBookmark) { if (clickedBookmark) {
console.log('Clicked bookmark:', clickedBookmark); console.debug('Clicked bookmark:', clickedBookmark);
if (this.args.onPlacesFound) { this.router.transitionTo('place', clickedBookmark);
this.args.onPlacesFound([], clickedBookmark); return;
} }
// Require Zoom >= 17 for generic map searches
// This prevents accidental searches when interacting with the map at a high level
const currentZoom = this.mapInstance.getView().getZoom();
if (currentZoom < 16) {
return; return;
} }
@@ -615,84 +771,32 @@ export default class MapComponent extends Component {
this.searchOverlayElement.style.width = `${diameterInPixels}px`; this.searchOverlayElement.style.width = `${diameterInPixels}px`;
this.searchOverlayElement.style.height = `${diameterInPixels}px`; this.searchOverlayElement.style.height = `${diameterInPixels}px`;
this.searchOverlay.setPosition(event.coordinate); this.searchOverlay.setPosition(event.coordinate);
this.searchOverlayElement.classList.add('active');
} }
// 2. Fetch authoritative data via Overpass // Start Search State
try { this.mapUi.startSearch();
let pois = await this.osm.getNearbyPois(lat, lon, searchRadius);
// Sort by distance from click // Transition to Search Route
pois = pois const queryParams = {
.map((p) => { lat: lat.toFixed(6),
// p is already normalized by service, so lat/lon are at top level lon: lon.toFixed(6),
return { };
...p, if (selectedFeatureName) {
_distance: getDistance(lat, lon, p.lat, p.lon), queryParams.q = selectedFeatureName;
};
})
.sort((a, b) => a._distance - b._distance);
let matchedPlace = null;
if (selectedFeatureName && pois.length > 0) {
// Heuristic:
// 1. Exact Name Match
matchedPlace = pois.find(
(p) =>
p.osmTags &&
(p.osmTags.name === selectedFeatureName ||
p.osmTags['name:en'] === selectedFeatureName)
);
// 2. If no exact match, look for VERY close (<=20m) and matching type
if (!matchedPlace) {
const topCandidate = pois[0];
if (topCandidate._distance <= 20) {
// Check type compatibility if available
// (visual tile 'class' is often 'cafe', osm tag is 'amenity'='cafe')
const pType =
topCandidate.osmTags.amenity ||
topCandidate.osmTags.shop ||
topCandidate.osmTags.tourism;
if (
selectedFeatureType &&
pType &&
(selectedFeatureType === pType ||
pType.includes(selectedFeatureType))
) {
console.log(
'Heuristic match found (distance + type):',
topCandidate
);
matchedPlace = topCandidate;
} else if (topCandidate._distance <= 10) {
// Even without type match, if it's super close (<=10m), it's likely the one.
console.log('Heuristic match found (proximity):', topCandidate);
matchedPlace = topCandidate;
}
}
}
}
if (this.args.onPlacesFound) {
this.args.onPlacesFound(pois, matchedPlace);
}
} catch (error) {
console.error('Failed to fetch POIs:', error);
} finally {
if (this.searchOverlayElement) {
this.searchOverlayElement.classList.remove('active');
}
} }
this.router.transitionTo('search', { queryParams });
}; };
<template> <template>
<div <div
class="map-container" class="map-container {{if @isSidebarOpen 'sidebar-open'}}"
{{this.setupMap}} {{this.setupMap}}
{{this.updateInteractions}}
{{this.updateBookmarks}} {{this.updateBookmarks}}
{{this.updateSelectedPin}} {{this.updateSelectedPin}}
{{this.syncPulse}}
{{this.syncCreationMode}}
></div> ></div>
</template> </template>
} }

View File

@@ -1,21 +1,15 @@
import Component from '@glimmer/component'; import Component from '@glimmer/component';
import { fn } from '@ember/helper'; import { fn } from '@ember/helper';
import { on } from '@ember/modifier'; import { on } from '@ember/modifier';
import capitalize from '../helpers/capitalize'; import { humanizeOsmTag } from '../utils/format-text';
import Icon from '../components/icon'; import Icon from '../components/icon';
import PlaceEditForm from './place-edit-form';
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 {
@tracked isEditing = false; @tracked isEditing = false;
@tracked editTitle = '';
@tracked editDescription = '';
constructor() {
super(...arguments);
this.resetEditFields();
}
get place() { get place() {
return this.args.place || {}; return this.args.place || {};
@@ -34,16 +28,9 @@ export default class PlaceDetails extends Component {
); );
} }
@action
resetEditFields() {
this.editTitle = this.name;
this.editDescription = this.place.description || '';
}
@action @action
startEditing() { startEditing() {
if (!this.place.createdAt) return; // Only allow editing saved places if (!this.place.createdAt) return; // Only allow editing saved places
this.resetEditFields();
this.isEditing = true; this.isEditing = true;
} }
@@ -53,37 +40,26 @@ export default class PlaceDetails extends Component {
} }
@action @action
async saveChanges(event) { async saveChanges(changes) {
event.preventDefault();
if (this.args.onSave) { if (this.args.onSave) {
await this.args.onSave({ await this.args.onSave({
...this.place, ...this.place,
title: this.editTitle, ...changes,
description: this.editDescription,
}); });
} }
this.isEditing = false; this.isEditing = false;
} }
@action
updateTitle(e) {
this.editTitle = e.target.value;
}
@action
updateDescription(e) {
this.editDescription = e.target.value;
}
get type() { get type() {
return ( const rawType =
this.tags.amenity || this.tags.amenity ||
this.tags.shop || this.tags.shop ||
this.tags.tourism || this.tags.tourism ||
this.tags.leisure || this.tags.leisure ||
this.tags.historic || this.tags.historic ||
'Point of Interest' 'Point of Interest';
);
return humanizeOsmTag(rawType);
} }
get address() { get address() {
@@ -133,8 +109,7 @@ export default class PlaceDetails extends Component {
if (!this.tags.cuisine) return null; if (!this.tags.cuisine) return null;
return this.tags.cuisine return this.tags.cuisine
.split(';') .split(';')
.map((c) => capitalize.compute([c])) .map((c) => humanizeOsmTag(c))
.map((c) => c.replace('_', ' '))
.join(', '); .join(', ');
} }
@@ -165,38 +140,19 @@ export default class PlaceDetails extends Component {
} }
get gmapsUrl() { get gmapsUrl() {
const id = this.place.gmapsId || this.place.osmId;
if (!id) return null;
return `https://www.google.com/maps/search/?api=1&query=${this.name}&query=${this.place.lat},${this.place.lon}`; return `https://www.google.com/maps/search/?api=1&query=${this.name}&query=${this.place.lat},${this.place.lon}`;
} }
<template> <template>
<div class="place-details"> <div class="place-details">
{{#if this.isEditing}} {{#if this.isEditing}}
<form class="edit-form" {{on "submit" this.saveChanges}}> <PlaceEditForm
<div class="form-group"> @place={{this.place}}
<label for="edit-title">Title</label> @onSave={{this.saveChanges}}
<input @onCancel={{this.cancelEditing}}
id="edit-title" />
type="text"
value={{this.editTitle}}
{{on "input" this.updateTitle}}
class="form-control"
/>
</div>
<div class="form-group">
<label for="edit-desc">Description</label>
<textarea
id="edit-desc"
value={{this.editDescription}}
{{on "input" this.updateDescription}}
class="form-control"
rows="3"
></textarea>
</div>
<div class="edit-actions">
<button type="submit" class="btn btn-blue btn-sm">Save</button>
<button type="button" class="btn btn-outline btn-sm" {{on "click" this.cancelEditing}}>Cancel</button>
</div>
</form>
{{else}} {{else}}
<h3>{{this.name}}</h3> <h3>{{this.name}}</h3>
<p class="place-type"> <p class="place-type">
@@ -305,7 +261,7 @@ export default class PlaceDetails extends Component {
{{#if this.osmUrl}} {{#if this.osmUrl}}
<p class="content-with-icon"> <p class="content-with-icon">
<Icon @name="map" @title="OSM ID" /> <Icon @name="map" />
<span> <span>
<a href={{this.osmUrl}} target="_blank" rel="noopener noreferrer"> <a href={{this.osmUrl}} target="_blank" rel="noopener noreferrer">
OpenStreetMap OpenStreetMap
@@ -314,14 +270,16 @@ export default class PlaceDetails extends Component {
</p> </p>
{{/if}} {{/if}}
<p class="content-with-icon"> {{#if this.gmapsUrl}}
<Icon @name="map" @title="OSM ID" /> <p class="content-with-icon">
<span> <Icon @name="map" />
<a href={{this.gmapsUrl}} target="_blank" rel="noopener noreferrer"> <span>
Google Maps <a href={{this.gmapsUrl}} target="_blank" rel="noopener noreferrer">
</a> Google Maps
</span> </a>
</p> </span>
</p>
{{/if}}
</div> </div>
</div> </div>

View File

@@ -0,0 +1,79 @@
import Component from '@glimmer/component';
import { on } from '@ember/modifier';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';
export default class PlaceEditForm extends Component {
@tracked title = '';
@tracked description = '';
constructor() {
super(...arguments);
this.title = this.args.place?.title || '';
this.description = this.args.place?.description || '';
}
get shouldAutofocus() {
if (typeof window !== 'undefined') {
return window.innerWidth > 768;
}
return false;
}
@action
handleSubmit(event) {
event.preventDefault();
if (this.args.onSave) {
this.args.onSave({
title: this.title,
description: this.description,
});
}
}
@action
updateTitle(e) {
this.title = e.target.value;
}
@action
updateDescription(e) {
this.description = e.target.value;
}
<template>
<form class="edit-form" {{on "submit" this.handleSubmit}}>
<div class="form-group">
<label for="edit-title">Title</label>
<input
id="edit-title"
type="text"
value={{this.title}}
{{on "input" this.updateTitle}}
class="form-control"
placeholder="Name of the place"
autofocus={{this.shouldAutofocus}}
/>
</div>
<div class="form-group">
<label for="edit-desc">Description</label>
<textarea
id="edit-desc"
value={{this.description}}
{{on "input" this.updateDescription}}
class="form-control"
rows="3"
placeholder="Add some details..."
></textarea>
</div>
<div class="edit-actions">
<button type="submit" class="btn btn-blue">Save</button>
<button
type="button"
class="btn btn-outline"
{{on "click" @onCancel}}
>Cancel</button>
</div>
</form>
</template>
}

View File

@@ -6,9 +6,26 @@ import { fn } from '@ember/helper';
import or from 'ember-truth-helpers/helpers/or'; import or from 'ember-truth-helpers/helpers/or';
import PlaceDetails from './place-details'; import PlaceDetails from './place-details';
import Icon from './icon'; import Icon from './icon';
import humanizeOsmTag from '../helpers/humanize-osm-tag';
export default class PlacesSidebar extends Component { export default class PlacesSidebar extends Component {
@service storage; @service storage;
@service router;
@service mapUi;
@action
createNewPlace() {
const qp = this.router.currentRoute.queryParams;
const lat = qp.lat;
const lon = qp.lon;
if (lat && lon) {
this.router.transitionTo('place.new', { queryParams: { lat, lon } });
} else {
// Fallback (shouldn't happen in search context)
this.router.transitionTo('place.new', { queryParams: { lat: 0, lon: 0 } });
}
}
@action @action
selectPlace(place) { selectPlace(place) {
@@ -23,13 +40,6 @@ export default class PlacesSidebar extends Component {
if (this.args.onSelect) { if (this.args.onSelect) {
this.args.onSelect(null); this.args.onSelect(null);
} }
// Fallback logic: if no list available, close sidebar
if (!this.args.places || this.args.places.length === 0) {
if (this.args.onClose) {
this.args.onClose();
}
}
} }
@action @action
@@ -40,19 +50,14 @@ export default class PlacesSidebar extends Component {
if (confirm(`Delete "${place.title}"?`)) { if (confirm(`Delete "${place.title}"?`)) {
try { try {
await this.storage.removePlace(place); await this.storage.removePlace(place);
console.log('Place deleted:', place.title); console.debug('Place deleted:', place.title);
// Notify parent to refresh map bookmarks // Notify parent to refresh map bookmarks
if (this.args.onBookmarkChange) { if (this.args.onBookmarkChange) {
this.args.onBookmarkChange(); this.args.onBookmarkChange();
} }
// Update selection to the new saved place object
// This updates the local UI state immediately without a route refresh
if (this.args.onUpdate) { if (this.args.onUpdate) {
// When deleting, we revert to a "fresh" object or just close.
// Since we close the sidebar below, we might not strictly need to update local state,
// but it's good practice.
// Reconstruct the "original" place without ID/Geohash/CreatedAt // Reconstruct the "original" place without ID/Geohash/CreatedAt
const freshPlace = { const freshPlace = {
...place, ...place,
@@ -65,7 +70,6 @@ export default class PlacesSidebar extends Component {
// Also fire onSelect if it exists (for list view) // Also fire onSelect if it exists (for list view)
if (this.args.onSelect) { if (this.args.onSelect) {
// Similar logic for select if needed, but we usually close.
this.args.onSelect(null); this.args.onSelect(null);
} }
@@ -94,7 +98,7 @@ export default class PlacesSidebar extends Component {
try { try {
const savedPlace = await this.storage.storePlace(placeData); const savedPlace = await this.storage.storePlace(placeData);
console.log('Place saved:', placeData.title); console.debug('Place saved:', placeData.title);
// Notify parent to refresh map bookmarks // Notify parent to refresh map bookmarks
if (this.args.onBookmarkChange) { if (this.args.onBookmarkChange) {
@@ -121,8 +125,8 @@ export default class PlacesSidebar extends Component {
async updateBookmark(updatedPlace) { async updateBookmark(updatedPlace) {
try { try {
const savedPlace = await this.storage.updatePlace(updatedPlace); const savedPlace = await this.storage.updatePlace(updatedPlace);
console.log('Place updated:', savedPlace.title); console.debug('Place updated:', savedPlace.title);
// Notify parent to refresh map/lists // Notify parent to refresh map/lists
if (this.args.onBookmarkChange) { if (this.args.onBookmarkChange) {
this.args.onBookmarkChange(); this.args.onBookmarkChange();
@@ -148,7 +152,7 @@ export default class PlacesSidebar extends Component {
{{on "click" this.clearSelection}} {{on "click" this.clearSelection}}
><Icon @name="arrow-left" @size={{20}} @color="#333" /></button> ><Icon @name="arrow-left" @size={{20}} @color="#333" /></button>
{{else}} {{else}}
<h2>Nearby Places</h2> <h2><Icon @name="target" @size={{20}} @color="#ea4335" /> Nearby</h2>
{{/if}} {{/if}}
<button type="button" class="close-btn" {{on "click" @onClose}}><Icon <button type="button" class="close-btn" {{on "click" @onClose}}><Icon
@name="x" @name="x"
@@ -180,13 +184,14 @@ export default class PlacesSidebar extends Component {
place.osmTags.name:en place.osmTags.name:en
"Unnamed Place" "Unnamed Place"
}}</div> }}</div>
<div class="place-type">{{or <div class="place-type">{{humanizeOsmTag (or
place.osmTags.amenity place.osmTags.amenity
place.osmTags.shop place.osmTags.shop
place.osmTags.tourism place.osmTags.tourism
place.osmTags.leisure place.osmTags.leisure
place.osmTags.historic place.osmTags.historic
}}</div> "Point of Interest"
)}}</div>
</button> </button>
</li> </li>
{{/each}} {{/each}}
@@ -194,6 +199,15 @@ export default class PlacesSidebar extends Component {
{{else}} {{else}}
<p class="empty-state">No places found nearby.</p> <p class="empty-state">No places found nearby.</p>
{{/if}} {{/if}}
<button
type="button"
class="btn btn-outline create-place"
{{on "click" this.createNewPlace}}
>
<Icon @name="plus" @size={{18}} @color="#007bff" />
Create new place
</button>
{{/if}} {{/if}}
</div> </div>
</div> </div>

View File

@@ -4,6 +4,7 @@ import { service } from '@ember/service';
import { action } from '@ember/object'; import { action } from '@ember/object';
import Icon from '#components/icon'; import Icon from '#components/icon';
import eq from 'ember-truth-helpers/helpers/eq'; import eq from 'ember-truth-helpers/helpers/eq';
import not from 'ember-truth-helpers/helpers/not';
export default class SettingsPane extends Component { export default class SettingsPane extends Component {
@service settings; @service settings;
@@ -13,6 +14,11 @@ export default class SettingsPane extends Component {
this.settings.updateOverpassApi(event.target.value); this.settings.updateOverpassApi(event.target.value);
} }
@action
toggleKinetic(event) {
this.settings.updateMapKinetic(event.target.value === 'true');
}
<template> <template>
<div class="sidebar settings-pane"> <div class="sidebar settings-pane">
<div class="sidebar-header"> <div class="sidebar-header">
@@ -25,6 +31,27 @@ export default class SettingsPane extends Component {
<div class="sidebar-content"> <div class="sidebar-content">
<section class="settings-section"> <section class="settings-section">
<h3>Settings</h3> <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={{if (not this.settings.mapKinetic) "selected"}}
>
Off
</option>
</select>
</div>
<div class="form-group"> <div class="form-group">
<label for="overpass-api">Overpass API Provider</label> <label for="overpass-api">Overpass API Provider</label>
<select <select

View File

@@ -1,8 +0,0 @@
import { helper } from '@ember/component/helper';
export function capitalize([str]) {
if (typeof str !== 'string') return '';
return str.charAt(0).toUpperCase() + str.slice(1);
}
export default helper(capitalize);

View File

@@ -0,0 +1,6 @@
import { helper } from '@ember/component/helper';
import { humanizeOsmTag as format } from '../utils/format-text';
export default helper(function humanizeOsmTag([text]) {
return format(text);
});

View File

@@ -8,4 +8,6 @@ export default class Router extends EmberRouter {
Router.map(function () { Router.map(function () {
this.route('place', { path: '/place/:place_id' }); this.route('place', { path: '/place/:place_id' });
this.route('place.new', { path: '/place/new' });
this.route('search');
}); });

View File

@@ -11,7 +11,7 @@ export default class PlaceRoute extends Route {
if (id.startsWith('osm:node:') || id.startsWith('osm:way:')) { if (id.startsWith('osm:node:') || id.startsWith('osm:way:')) {
const [, type, osmId] = id.split(':'); const [, type, osmId] = id.split(':');
console.log(`Fetching explicit OSM ${type}:`, osmId); console.debug(`Fetching explicit OSM ${type}:`, osmId);
return this.loadOsmPlace(osmId, type); return this.loadOsmPlace(osmId, type);
} }
@@ -20,7 +20,7 @@ export default class PlaceRoute extends Route {
let bookmark = this.storage.findPlaceById(id); let bookmark = this.storage.findPlaceById(id);
if (bookmark) { if (bookmark) {
console.log('Found in bookmarks:', bookmark.title); console.debug('Found in bookmarks:', bookmark.title);
return bookmark; return bookmark;
} }
@@ -31,7 +31,7 @@ export default class PlaceRoute extends Route {
async waitForSync() { async waitForSync() {
if (this.storage.initialSyncDone) return; if (this.storage.initialSyncDone) return;
console.log('Waiting for initial storage sync...'); console.debug('Waiting for initial storage sync...');
const timeout = 5000; const timeout = 5000;
const start = Date.now(); const start = Date.now();
@@ -49,11 +49,15 @@ export default class PlaceRoute extends Route {
if (model) { if (model) {
this.mapUi.selectPlace(model); this.mapUi.selectPlace(model);
} }
// Stop the pulse animation if it was running (e.g. redirected from search)
this.mapUi.stopSearch();
} }
deactivate() { deactivate() {
// Clear the pin when leaving the route // Clear the pin when leaving the route
this.mapUi.clearSelection(); this.mapUi.clearSelection();
// Reset the "return to search" flag so it doesn't persist to subsequent navigations
this.mapUi.returnToSearch = false;
} }
async loadOsmPlace(id, type = null) { async loadOsmPlace(id, type = null) {

30
app/routes/place/new.js Normal file
View File

@@ -0,0 +1,30 @@
import Route from '@ember/routing/route';
import { service } from '@ember/service';
export default class PlaceNewRoute extends Route {
@service mapUi;
queryParams = {
lat: { refreshModel: true },
lon: { refreshModel: true },
};
model(params) {
return {
lat: parseFloat(params.lat),
lon: parseFloat(params.lon),
};
}
setupController(controller, model) {
super.setupController(controller, model);
if (model.lat && model.lon) {
this.mapUi.updateCreationCoordinates(model.lat, model.lon);
}
this.mapUi.startCreating();
}
deactivate() {
this.mapUi.stopCreating();
}
}

116
app/routes/search.js Normal file
View File

@@ -0,0 +1,116 @@
import Route from '@ember/routing/route';
import { service } from '@ember/service';
import { action } from '@ember/object';
import { getDistance } from '../utils/geo';
export default class SearchRoute extends Route {
@service osm;
@service mapUi;
@service storage;
@service router;
queryParams = {
lat: { refreshModel: true },
lon: { refreshModel: true },
q: { refreshModel: true },
};
async model(params) {
// If no coordinates, we can't search
if (!params.lat || !params.lon) {
return [];
}
const lat = parseFloat(params.lat);
const lon = parseFloat(params.lon);
const searchRadius = params.q ? 30 : 50;
// Fetch POIs
let pois = await this.osm.getNearbyPois(lat, lon, searchRadius);
// Get cached/saved places in search radius
const localMatches = this.storage.savedPlaces.filter((p) => {
const dist = getDistance(lat, lon, p.lat, p.lon);
return dist <= searchRadius;
});
// Add local matches to the list if they aren't already there
// We use osmId to deduplicate if possible
localMatches.forEach((local) => {
const exists = pois.find(
(poi) =>
(local.osmId && poi.osmId === local.osmId) ||
(poi.id && poi.id === local.id)
);
if (!exists) {
pois.push(local);
}
});
// Sort by distance from click
pois = pois
.map((p) => {
return {
...p,
_distance: getDistance(lat, lon, p.lat, p.lon),
};
})
.sort((a, b) => a._distance - b._distance);
// Check if any of these are already bookmarked
// We resolve them to the bookmark version if they exist
pois = pois.map((p) => {
const saved = this.storage.findPlaceById(p.osmId);
return saved || p;
});
return pois;
}
afterModel(model, transition) {
const { q } = transition.to.queryParams;
// Heuristic Match Logic (ported from MapComponent)
if (q && model.length > 0) {
let matchedPlace = null;
// 1. Exact Name Match
matchedPlace = model.find(
(p) => p.osmTags && (p.osmTags.name === q || p.osmTags['name:en'] === q)
);
// 2. High Proximity Match (<= 10m)
// Note: MapComponent had logic for <=20m + type match.
// We might want to pass the 'type' in queryParams if we want to be that precise.
// For now, let's stick to name or very close proximity.
if (!matchedPlace) {
const topCandidate = model[0];
if (topCandidate._distance <= 10) {
matchedPlace = topCandidate;
}
}
if (matchedPlace) {
// Direct transition!
this.router.replaceWith('place', matchedPlace);
return;
}
}
// Stop the pulse animation since search is done (and we are staying here)
this.mapUi.stopSearch();
}
setupController(controller, model) {
super.setupController(controller, model);
// Ensure pulse is stopped if we reach here
this.mapUi.stopSearch();
}
@action
error() {
this.mapUi.stopSearch();
return true; // Bubble error
}
}

View File

@@ -3,6 +3,10 @@ import { tracked } from '@glimmer/tracking';
export default class MapUiService extends Service { export default class MapUiService extends Service {
@tracked selectedPlace = null; @tracked selectedPlace = null;
@tracked isSearching = false;
@tracked isCreating = false;
@tracked creationCoordinates = null;
@tracked returnToSearch = false;
selectPlace(place) { selectPlace(place) {
this.selectedPlace = place; this.selectedPlace = place;
@@ -11,4 +15,27 @@ export default class MapUiService extends Service {
clearSelection() { clearSelection() {
this.selectedPlace = null; this.selectedPlace = null;
} }
startSearch() {
this.isSearching = true;
this.isCreating = false;
}
stopSearch() {
this.isSearching = false;
}
startCreating() {
this.isCreating = true;
this.isSearching = false;
}
stopCreating() {
this.isCreating = false;
this.creationCoordinates = null;
}
updateCreationCoordinates(lat, lon) {
this.creationCoordinates = { lat, lon };
}
} }

View File

@@ -4,8 +4,18 @@ export default class OsmService extends Service {
@service settings; @service settings;
controller = null; controller = null;
cachedResults = null;
lastQueryKey = null;
async getNearbyPois(lat, lon, radius = 50) { async getNearbyPois(lat, lon, radius = 50) {
const queryKey = `${lat},${lon},${radius}`;
// Return cached results if the query is identical to the last one
if (this.lastQueryKey === queryKey && this.cachedResults) {
console.debug('Returning cached Overpass results for:', queryKey);
return this.cachedResults;
}
// Cancel previous request if it exists // Cancel previous request if it exists
if (this.controller) { if (this.controller) {
this.controller.abort(); this.controller.abort();
@@ -33,10 +43,16 @@ out center;
const data = await res.json(); const data = await res.json();
// Normalize data // Normalize data
return data.elements.map(this.normalizePoi); const results = data.elements.map(this.normalizePoi);
// Update cache
this.lastQueryKey = queryKey;
this.cachedResults = results;
return results;
} catch (e) { } catch (e) {
if (e.name === 'AbortError') { if (e.name === 'AbortError') {
console.log('Overpass request aborted'); console.debug('Overpass request aborted');
return []; return [];
} }
throw e; throw e;
@@ -62,7 +78,7 @@ out center;
const res = await fetch(url, options); const res = await fetch(url, options);
if (!res.ok && retries > 0 && [502, 503, 504, 429].includes(res.status)) { if (!res.ok && retries > 0 && [502, 503, 504, 429].includes(res.status)) {
console.log( console.warn(
`Overpass request failed with ${res.status}. Retrying... (${retries} left)` `Overpass request failed with ${res.status}. Retrying... (${retries} left)`
); );
await new Promise((r) => setTimeout(r, 1000)); await new Promise((r) => setTimeout(r, 1000));
@@ -72,7 +88,7 @@ out center;
return res; return res;
} catch (e) { } catch (e) {
if (retries > 0 && e.name !== 'AbortError') { if (retries > 0 && e.name !== 'AbortError') {
console.log(`Retrying Overpass request... (${retries} left)`); console.debug(`Retrying Overpass request... (${retries} left)`);
await new Promise((r) => setTimeout(r, 1000)); await new Promise((r) => setTimeout(r, 1000));
return this.fetchWithRetry(url, options, retries - 1); return this.fetchWithRetry(url, options, retries - 1);
} }

View File

@@ -3,6 +3,7 @@ import { tracked } from '@glimmer/tracking';
export default class SettingsService extends Service { export default class SettingsService extends Service {
@tracked overpassApi = 'https://overpass.bke.ro/api/interpreter'; @tracked overpassApi = 'https://overpass.bke.ro/api/interpreter';
@tracked mapKinetic = true;
overpassApis = [ overpassApis = [
{ name: 'bke.ro', url: 'https://overpass.bke.ro/api/interpreter' }, { name: 'bke.ro', url: 'https://overpass.bke.ro/api/interpreter' },
@@ -19,14 +20,25 @@ export default class SettingsService extends Service {
} }
loadSettings() { loadSettings() {
const savedApi = localStorage.getItem('marco-overpass-api'); const savedApi = localStorage.getItem('marco:overpass-api');
if (savedApi) { if (savedApi) {
this.overpassApi = savedApi; this.overpassApi = savedApi;
} }
const savedKinetic = localStorage.getItem('marco:map-kinetic');
if (savedKinetic !== null) {
this.mapKinetic = savedKinetic === 'true';
}
// Default is true (initialized in class field)
} }
updateOverpassApi(url) { updateOverpassApi(url) {
this.overpassApi = url; this.overpassApi = url;
localStorage.setItem('marco-overpass-api', url); localStorage.setItem('marco:overpass-api', url);
}
updateMapKinetic(enabled) {
this.mapKinetic = enabled;
localStorage.setItem('marco:map-kinetic', String(enabled));
} }
} }

View File

@@ -23,7 +23,6 @@ export default class StorageService extends Service {
constructor() { constructor() {
super(...arguments); super(...arguments);
console.log('ohai');
this.rs = new RemoteStorage({ this.rs = new RemoteStorage({
modules: [Places], modules: [Places],
@@ -45,13 +44,11 @@ export default class StorageService extends Service {
}); });
this.rs.on('connected', () => { this.rs.on('connected', () => {
console.debug('Remote storage connected');
this.connected = true; this.connected = true;
this.userAddress = this.rs.remote.userAddress; this.userAddress = this.rs.remote.userAddress;
}); });
this.rs.on('disconnected', () => { this.rs.on('disconnected', () => {
console.debug('Remote storage disconnected');
this.connected = false; this.connected = false;
this.userAddress = null; this.userAddress = null;
this.placesInView = []; this.placesInView = [];
@@ -77,18 +74,7 @@ export default class StorageService extends Service {
handlePlaceChange(event) { handlePlaceChange(event) {
const { newValue, relativePath } = event; const { newValue, relativePath } = event;
// Remove old entry if exists // Extract ID from path (structure: <2-char>/<2-char>/<id>)
// The relativePath is like "geohash/geohash/ULID" or just "ULID" depending on structure.
// Our structure is <2-char>/<2-char>/<id>.
// But let's rely on the ID inside the object if possible, or extract from path.
// We can't easily identify the ID from just relativePath without parsing logic if it's nested.
// However, for deletions (newValue is undefined), we might need the ID.
// Fortunately, our objects (newValue) contain the ID.
// If it's a deletion, we need to find the object in our array to remove it.
// Since we don't have the ID in newValue (it's null), we rely on `relativePath`.
// Let's assume the filename is the ID.
const pathParts = relativePath.split('/'); const pathParts = relativePath.split('/');
const id = pathParts[pathParts.length - 1]; const id = pathParts[pathParts.length - 1];
@@ -128,7 +114,7 @@ export default class StorageService extends Service {
// Recalculate prefixes for the current view // Recalculate prefixes for the current view
const required = getGeohashPrefixesInBbox(this.currentBbox); const required = getGeohashPrefixesInBbox(this.currentBbox);
console.log('Reloading view due to changes, prefixes:', required); console.debug('Reloading view due to changes, prefixes:', required);
// Force load these prefixes (bypassing the 'already loaded' check in loadPlacesInBounds) // Force load these prefixes (bypassing the 'already loaded' check in loadPlacesInBounds)
this.loadAllPlaces(required); this.loadAllPlaces(required);
@@ -144,20 +130,15 @@ export default class StorageService extends Service {
); );
if (missingPrefixes.length === 0) { if (missingPrefixes.length === 0) {
// console.log('All prefixes already loaded for this view');
return; return;
} }
console.log('Loading new prefixes:', missingPrefixes); console.debug('Loading new prefixes:', missingPrefixes);
// 3. Load places for only the new prefixes // 3. Load places for only the new prefixes
await this.loadAllPlaces(missingPrefixes); await this.loadAllPlaces(missingPrefixes);
// 4. Update our tracked list of loaded prefixes // 4. Update our tracked list of loaded prefixes
// Using assignment to trigger reactivity if needed, though simple push/mutation might suffice
// depending on usage. Tracked arrays need reassignment or specific Ember array methods
// if we want to observe the array itself, but here we just check inclusion.
// Let's do a reassignment to be safe and clean.
this.loadedPrefixes = [...this.loadedPrefixes, ...missingPrefixes]; this.loadedPrefixes = [...this.loadedPrefixes, ...missingPrefixes];
this.currentBbox = bbox; this.currentBbox = bbox;
} }
@@ -195,7 +176,7 @@ export default class StorageService extends Service {
} else { } else {
if (!prefixes) this.placesInView = []; if (!prefixes) this.placesInView = [];
} }
console.log('Loaded saved places:', this.placesInView.length); console.debug('Loaded saved places:', this.placesInView.length);
} catch (e) { } catch (e) {
console.error('Failed to load places:', e); console.error('Failed to load places:', e);
} }
@@ -216,29 +197,56 @@ export default class StorageService extends Service {
async storePlace(placeData) { async storePlace(placeData) {
const savedPlace = await this.places.store(placeData); const savedPlace = await this.places.store(placeData);
// Only append if not already there (handlePlaceChange might also fire)
// Optimistic Update: Global List
if (!this.savedPlaces.some((p) => p.id === savedPlace.id)) { if (!this.savedPlaces.some((p) => p.id === savedPlace.id)) {
this.savedPlaces = [...this.savedPlaces, savedPlace]; this.savedPlaces = [...this.savedPlaces, savedPlace];
} else {
// Update if exists
this.savedPlaces = this.savedPlaces.map((p) =>
p.id === savedPlace.id ? savedPlace : p
);
} }
// Optimistic Update: Map View (same logic as Global List)
if (!this.placesInView.some((p) => p.id === savedPlace.id)) {
this.placesInView = [...this.placesInView, savedPlace];
} else {
this.placesInView = this.placesInView.map((p) =>
p.id === savedPlace.id ? savedPlace : p
);
}
return savedPlace; return savedPlace;
} }
async updatePlace(placeData) { async updatePlace(placeData) {
const savedPlace = await this.places.store(placeData); const savedPlace = await this.places.store(placeData);
// Update local list // Optimistic Update: Global List
const index = this.savedPlaces.findIndex((p) => p.id === savedPlace.id); const index = this.savedPlaces.findIndex((p) => p.id === savedPlace.id);
if (index !== -1) { if (index !== -1) {
const newPlaces = [...this.savedPlaces]; const newPlaces = [...this.savedPlaces];
newPlaces[index] = savedPlace; newPlaces[index] = savedPlace;
this.savedPlaces = newPlaces; this.savedPlaces = newPlaces;
} }
// Update Map View
this.placesInView = this.placesInView.map((p) =>
p.id === savedPlace.id ? savedPlace : p
);
return savedPlace; return savedPlace;
} }
async removePlace(place) { async removePlace(place) {
await this.places.remove(place.id, place.geohash); await this.places.remove(place.id, place.geohash);
// Update both lists
this.savedPlaces = this.savedPlaces.filter((p) => p.id !== place.id); this.savedPlaces = this.savedPlaces.filter((p) => p.id !== place.id);
if (this.placesInView.length > 0) {
this.placesInView = this.placesInView.filter((p) => p.id !== place.id);
}
} }
@action @action

View File

@@ -4,11 +4,19 @@ html,
body { body {
height: 100%; height: 100%;
overscroll-behavior: none; /* Prevent pull-to-refresh on mobile */ overscroll-behavior: none; /* Prevent pull-to-refresh on mobile */
-webkit-text-size-adjust: 100%;
-webkit-tap-highlight-color: transparent;
}
button {
-webkit-tap-highlight-color: transparent;
} }
body { body {
margin: 0; margin: 0;
font-family: 'Noto Serif', serif; font-family: 'Noto Serif', sans-serif;
font-size: 16px;
color: #333;
} }
#root, #root,
@@ -66,7 +74,15 @@ body {
pointer-events: auto; /* Re-enable clicks for buttons */ pointer-events: auto; /* Re-enable clicks for buttons */
} }
.icon-btn { .btn-press {
transition: transform 0.1s;
}
.btn-press:active {
transform: scale(0.95);
}
.menu-btn {
background: white; background: white;
border: none; border: none;
border-radius: 50%; border-radius: 50%;
@@ -77,11 +93,6 @@ body {
justify-content: center; justify-content: center;
box-shadow: 0 2px 5px rgb(0 0 0 / 20%); box-shadow: 0 2px 5px rgb(0 0 0 / 20%);
cursor: pointer; cursor: pointer;
transition: transform 0.1s;
}
.icon-btn:active {
transform: scale(0.95);
} }
.user-btn { .user-btn {
@@ -94,7 +105,7 @@ body {
.user-avatar-placeholder { .user-avatar-placeholder {
width: 40px; width: 40px;
height: 40px; height: 40px;
background: #333; background: #2a3743;
border-radius: 50%; border-radius: 50%;
display: flex; display: flex;
align-items: center; align-items: center;
@@ -190,7 +201,6 @@ body {
bottom: 0; bottom: 0;
width: 300px; width: 300px;
background: white; background: white;
color: #333;
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;
@@ -224,10 +234,15 @@ body {
.sidebar-header h2 { .sidebar-header h2 {
margin: 0; margin: 0;
font-size: 1.2rem; font-size: 1.2rem;
display: flex;
align-items: center;
gap: 0.5rem;
} }
.sidebar-content { .sidebar-content {
padding: 1rem; padding: 1rem;
overflow-y: auto;
flex: 1; /* Take up remaining vertical space */
} }
.edit-form { .edit-form {
@@ -255,7 +270,7 @@ body {
border: 1px solid #ddd; border: 1px solid #ddd;
border-radius: 4px; border-radius: 4px;
font-family: inherit; font-family: inherit;
font-size: 0.95rem; font-size: 1rem;
box-sizing: border-box; /* Ensure padding doesn't overflow width */ box-sizing: border-box; /* Ensure padding doesn't overflow width */
} }
@@ -319,6 +334,11 @@ body {
font-size: 0.9rem; font-size: 0.9rem;
} }
.meta-info p {
margin-top: 1rem;
margin-bottom: 1rem;
}
.meta-info p:first-child { .meta-info p:first-child {
margin-top: 1.2rem; margin-top: 1.2rem;
padding-top: 1.2rem; padding-top: 1.2rem;
@@ -358,29 +378,31 @@ body {
.places-list { .places-list {
list-style: none; list-style: none;
padding: 0; padding: 0;
margin: 0; margin: -1rem -1rem 0 -1rem;
} }
.places-list li { .places-list li {
margin-bottom: 0.5rem;
} }
.place-item { .place-item {
width: 100%; width: 100%;
text-align: left; text-align: left;
background: #f8f9fa; border: none;
border: 1px solid #ddd; border-bottom: 1px solid #eee;
padding: 0.75rem; background: #fff;
border-radius: 4px; color: #333;
padding: 1rem;
cursor: pointer; cursor: pointer;
transition: background 0.2s; transition: background 0.2s;
font-family: inherit;
} }
.place-item:hover { .place-item:hover {
background: #e9ecef; background: #eee;
} }
.place-name { .place-name {
font-size: 1rem;
font-weight: bold; font-weight: bold;
margin-bottom: 0.25rem; margin-bottom: 0.25rem;
} }
@@ -388,7 +410,6 @@ body {
.place-type { .place-type {
color: #666; color: #666;
font-size: 0.85rem; font-size: 0.85rem;
text-transform: capitalize;
} }
.back-btn { .back-btn {
@@ -422,12 +443,11 @@ body {
.place-details .place-type { .place-details .place-type {
color: #666; color: #666;
font-size: 0.9rem; font-size: 0.9rem;
text-transform: capitalize;
margin: 0 0 1rem; margin: 0 0 1rem;
} }
.place-details .place-description { .place-details p.place-description {
margin-bottom: 1.5rem; line-height: 1.4;
} }
.place-details .actions { .place-details .actions {
@@ -435,24 +455,20 @@ body {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
gap: 1rem; gap: 1rem;
margin-top: 1.5rem;
} }
.btn { .btn {
padding: 0.75rem 1.5rem; padding: 0.6rem 1.2rem;
border-radius: 4px; border-radius: 4px;
cursor: pointer; cursor: pointer;
font-size: 1rem; font-size: 0.9rem;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
gap: 0.5rem; gap: 0.5rem;
} }
.btn-sm {
padding: 0.4rem 1rem !important;
font-size: 0.9rem !important;
}
.btn-outline { .btn-outline {
background: transparent; background: transparent;
color: #333; color: #333;
@@ -499,11 +515,15 @@ body {
border: 2px solid rgb(255 204 51 / 80%); /* Gold/Yellow to match markers */ border: 2px solid rgb(255 204 51 / 80%); /* Gold/Yellow to match markers */
background: rgb(255 204 51 / 20%); background: rgb(255 204 51 / 20%);
position: absolute; position: absolute;
transform: translate(-50%, -50%); /* Use translate3d for GPU acceleration on iOS */
transform: translate3d(-50%, -50%, 0);
pointer-events: none; pointer-events: none;
animation: pulse 1.5s infinite ease-out; animation: pulse 1.5s infinite ease-out;
box-sizing: border-box; /* Ensure border is included in width/height */ box-sizing: border-box; /* Ensure border is included in width/height */
display: none; /* Hidden by default */ display: none; /* Hidden by default */
will-change: transform, opacity;
-webkit-backface-visibility: hidden;
backface-visibility: hidden;
} }
.search-pulse.active { .search-pulse.active {
@@ -517,32 +537,50 @@ body {
@keyframes pulse { @keyframes pulse {
0% { 0% {
transform: translate(-50%, -50%) scale(0.8); transform: translate3d(-50%, -50%, 0) scale(0.8);
opacity: 0.8; opacity: 0.8;
} }
100% { 100% {
transform: translate(-50%, -50%) scale(1.4); transform: translate3d(-50%, -50%, 0) scale(1.4);
opacity: 0; opacity: 0;
} }
} }
/* Locate Control */ /* Zoom Control - Moved to bottom right above attribution */
.ol-zoom {
top: auto !important;
left: auto !important;
bottom: 2.5em;
right: 0.5em;
}
.ol-touch .ol-zoom {
bottom: 3.5em;
}
/* Locate Control - Above Zoom */
.ol-control.ol-locate { .ol-control.ol-locate {
inset: auto 0.5em 2.5em auto; top: auto !important;
left: auto !important;
bottom: 6.5em;
right: 0.5em;
} }
.ol-touch .ol-control.ol-locate { .ol-touch .ol-control.ol-locate {
inset: auto auto 3.5em; bottom: 8.5em;
} }
/* Rotate Control */ /* Rotate Control - Above Locate */
.ol-rotate { .ol-rotate {
inset: auto 0.5em 5em auto; top: auto !important;
left: auto !important;
bottom: 9em;
right: 0.5em;
} }
.ol-touch .ol-rotate { .ol-touch .ol-rotate {
inset: auto auto 6em; bottom: 11.5em;
} }
span.icon { span.icon {
@@ -637,6 +675,63 @@ span.icon {
} }
} }
/* Map Crosshair for "Create Place" mode */
.map-crosshair {
position: absolute;
/* Default Center */
top: 50%;
left: 50%;
width: 24px;
height: 24px;
transform: translate(-50%, -50%);
color: #333;
pointer-events: none;
z-index: 2000;
display: none;
transition:
top 0.3s ease,
left 0.3s ease;
}
.map-crosshair.visible {
display: block;
}
/* Sidebar is open (Desktop: Left 300px) */
/* We want to center in the remaining space (width - 300px) */
/* 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 {
left: calc(50% + 150px);
}
@media (width <= 768px) {
/* Sidebar/Bottom Sheet is open (Mobile: Bottom 50%) */
/* Center Y = (height/2) / 2 = height/4 = 25% */
.map-container.sidebar-open .map-crosshair {
left: 50%; /* Reset desktop shift */
top: 25%;
}
}
.helper-text {
background: #eef4fc;
color: #1a5c9b;
padding: 0.75rem;
border-radius: 4px;
font-size: 0.9rem;
margin-top: 0;
margin-bottom: 1rem;
display: flex;
align-items: center;
gap: 0.5rem;
}
button.create-place {
width: 100%;
margin: 1.5rem auto;
}
@media (width <= 768px) { @media (width <= 768px) {
.sidebar { .sidebar {
width: 100%; width: 100%;

View File

@@ -1,61 +1,38 @@
import Component from '@glimmer/component'; 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 PlacesSidebar from '#components/places-sidebar';
import AppHeader from '#components/app-header'; import AppHeader from '#components/app-header';
import SettingsPane from '#components/settings-pane'; import SettingsPane from '#components/settings-pane';
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';
import { eq } from 'ember-truth-helpers'; import { or } from 'ember-truth-helpers';
import { and, or } from 'ember-truth-helpers';
import { on } from '@ember/modifier'; import { on } from '@ember/modifier';
export default class ApplicationComponent extends Component { export default class ApplicationComponent extends Component {
@service storage; @service storage;
@service mapUi;
@service router; @service router;
@tracked nearbyPlaces = null;
@tracked isSettingsOpen = false; @tracked isSettingsOpen = false;
// @tracked bookmarksVersion = 0; // Moved to storage service
get isSidebarOpen() { get isSidebarOpen() {
return !!this.nearbyPlaces || this.router.currentRouteName === 'place'; // We consider the sidebar "open" if we are in search or place routes.
// This helps the map know if it should shift the center or adjust view.
return (
this.router.currentRouteName === 'place' ||
this.router.currentRouteName === 'place.new' ||
this.router.currentRouteName === 'search'
);
} }
constructor() { constructor() {
super(...arguments); super(...arguments);
console.log('Application component constructed'); console.debug('Application component constructed');
// Access the service to ensure it is instantiated // Access the service to ensure it is instantiated
this.storage; this.storage;
} }
@action
showPlaces(places, selectedPlace = null) {
// Helper to resolve a place to its bookmark if it exists
const resolvePlace = (p) => {
if (!p) return null;
// We use the OSM ID to check if we already have this place saved
const saved = this.storage.findPlaceById(p.osmId);
return saved || p;
};
const resolvedSelected = resolvePlace(selectedPlace);
const resolvedPlaces = places ? places.map(resolvePlace) : [];
// If we have a specific place, transition to the route
if (resolvedSelected) {
// Pass the FULL object model to avoid re-fetching!
// The Route's serialize() hook handles URL generation.
this.router.transitionTo('place', resolvedSelected);
this.nearbyPlaces = null; // Clear list when selecting specific
} else if (resolvedPlaces && resolvedPlaces.length > 0) {
// Show list case
this.nearbyPlaces = resolvedPlaces;
this.router.transitionTo('index');
}
}
@action @action
toggleSettings() { toggleSettings() {
this.isSettingsOpen = !this.isSettingsOpen; this.isSettingsOpen = !this.isSettingsOpen;
@@ -66,29 +43,20 @@ export default class ApplicationComponent extends Component {
this.isSettingsOpen = false; this.isSettingsOpen = false;
} }
@action
selectFromList(place) {
if (place) {
// Optimize: Pass full object to avoid fetch
this.router.transitionTo('place', place);
}
}
@action @action
handleOutsideClick() { handleOutsideClick() {
if (this.isSettingsOpen) { if (this.isSettingsOpen) {
this.closeSettings(); this.closeSettings();
} else { } else if (this.router.currentRouteName === 'search') {
this.closeSidebar(); this.router.transitionTo('index');
} else if (this.router.currentRouteName === 'place') {
// If in place route, decide if we want to go back to search or index
// For now, let's go to index or maybe back to search if search params exist?
// Simplest behavior: clear selection
this.router.transitionTo('index');
} }
} }
@action
closeSidebar() {
this.nearbyPlaces = null;
this.router.transitionTo('index');
}
@action @action
refreshBookmarks() { refreshBookmarks() {
this.storage.notifyChange(); this.storage.notifyChange();
@@ -113,19 +81,10 @@ export default class ApplicationComponent extends Component {
{{/if}} {{/if}}
<Map <Map
@onPlacesFound={{this.showPlaces}}
@isSidebarOpen={{or this.isSidebarOpen this.isSettingsOpen}} @isSidebarOpen={{or this.isSidebarOpen this.isSettingsOpen}}
@onOutsideClick={{this.handleOutsideClick}} @onOutsideClick={{this.handleOutsideClick}}
/> />
{{#if (and (eq this.router.currentRouteName "index") this.nearbyPlaces)}}
<PlacesSidebar
@places={{this.nearbyPlaces}}
@onSelect={{this.selectFromList}}
@onClose={{this.closeSidebar}}
/>
{{/if}}
{{#if this.isSettingsOpen}} {{#if this.isSettingsOpen}}
<SettingsPane @onClose={{this.closeSettings}} /> <SettingsPane @onClose={{this.closeSettings}} />
{{/if}} {{/if}}

View File

@@ -7,6 +7,7 @@ import { tracked } from '@glimmer/tracking';
export default class PlaceTemplate extends Component { export default class PlaceTemplate extends Component {
@service router; @service router;
@service storage; @service storage;
@service mapUi;
@tracked localPlace = null; @tracked localPlace = null;
@@ -62,7 +63,7 @@ export default class PlaceTemplate extends Component {
@action @action
handleUpdate(newPlace) { handleUpdate(newPlace) {
console.log('Updating local place state:', newPlace); console.debug('Updating local place state:', newPlace);
this.localPlace = newPlace; this.localPlace = newPlace;
this.storage.notifyChange(); this.storage.notifyChange();
} }
@@ -72,8 +73,26 @@ export default class PlaceTemplate extends Component {
this.storage.notifyChange(); this.storage.notifyChange();
} }
@action
navigateBack(place) {
// The sidebar calls this with null when "Back" is clicked.
if (place === null) {
// If we came from search results, go back in history
if (this.mapUi.returnToSearch) {
window.history.back();
} else {
// Otherwise just close the sidebar (return to map index)
this.router.transitionTo('index');
}
} else {
// If a place is selected (unlikely in this view, but possible if we add related links)
this.router.transitionTo('place', place);
}
}
@action @action
close() { close() {
// Clear search results so we don't fall back to the list
this.router.transitionTo('index'); this.router.transitionTo('index');
} }
@@ -81,6 +100,7 @@ export default class PlaceTemplate extends Component {
<PlacesSidebar <PlacesSidebar
@selectedPlace={{this.place}} @selectedPlace={{this.place}}
@onClose={{this.close}} @onClose={{this.close}}
@onSelect={{this.navigateBack}}
@onBookmarkChange={{this.refreshMap}} @onBookmarkChange={{this.refreshMap}}
@onUpdate={{this.handleUpdate}} @onUpdate={{this.handleUpdate}}
/> />

View File

@@ -0,0 +1,83 @@
import Component from '@glimmer/component';
import { service } from '@ember/service';
import { action } from '@ember/object';
import { on } from '@ember/modifier';
import PlaceEditForm from '#components/place-edit-form';
import Icon from '#components/icon';
export default class PlaceNewTemplate extends Component {
@service router;
@service storage;
@service mapUi;
get initialPlace() {
return {
title: '',
description: '',
};
}
@action
close() {
this.router.transitionTo('index');
}
@action
async savePlace(changes) {
try {
// Use coordinates from Map UI (which tracks the crosshair center)
// Fallback to URL params if map state isn't ready
const center = this.mapUi.creationCoordinates || {
lat: this.args.model.lat,
lon: this.args.model.lon,
};
const lat = parseFloat(center.lat.toFixed(6));
const lon = parseFloat(center.lon.toFixed(6));
const placeData = {
title: changes.title || 'Untitled Place',
description: changes.description,
lat: lat,
lon: lon,
tags: [],
osmTags: {},
};
const savedPlace = await this.storage.storePlace(placeData);
console.debug('Created private place:', savedPlace.title);
// Transition to the new place
this.router.replaceWith('place', savedPlace);
} catch (e) {
console.error('Failed to create place:', e);
alert('Failed to create place: ' + e.message);
}
}
<template>
<div class="sidebar">
<div class="sidebar-header">
<h2><Icon @name="plus-circle" @size={{20}} @color="#ea4335" />
New Place</h2>
<button type="button" class="close-btn" {{on "click" this.close}}><Icon
@name="x"
@size={{20}}
@color="#333"
/></button>
</div>
<div class="sidebar-content">
<p class="helper-text">
Drag the map to position the crosshair.
</p>
<PlaceEditForm
@place={{this.initialPlace}}
@onSave={{this.savePlace}}
@onCancel={{this.close}}
/>
</div>
</div>
</template>
}

30
app/templates/search.gjs Normal file
View File

@@ -0,0 +1,30 @@
import Component from '@glimmer/component';
import PlacesSidebar from '#components/places-sidebar';
import { service } from '@ember/service';
import { action } from '@ember/object';
export default class SearchTemplate extends Component {
@service router;
@service mapUi;
@action
selectPlace(place) {
if (place) {
this.mapUi.returnToSearch = true;
this.router.transitionTo('place', place);
}
}
@action
close() {
this.router.transitionTo('index');
}
<template>
<PlacesSidebar
@places={{@model}}
@onSelect={{this.selectPlace}}
@onClose={{this.close}}
/>
</template>
}

9
app/utils/format-text.js Normal file
View File

@@ -0,0 +1,9 @@
export function humanizeOsmTag(text) {
if (typeof text !== 'string' || !text) return '';
// Replace underscores and dashes with spaces
const spaced = text.replace(/[_-]/g, ' ');
// Capitalize first letter of each word (Title Case)
return spaced.replace(/\w\S*/g, (w) =>
w.replace(/^\w/, (c) => c.toUpperCase())
);
}

View File

@@ -9,7 +9,7 @@
<!-- App identity --> <!-- App identity -->
<meta name="application-name" content="Marco"> <meta name="application-name" content="Marco">
<meta name="apple-mobile-web-app-title" content="Marco"> <meta name="apple-mobile-web-app-title" content="Marco">
<meta name="theme-color" content="#333333"> <meta name="theme-color" content="#2a3743">
<!-- PWA Manifest --> <!-- PWA Manifest -->
<link rel="manifest" href="/web-app-manifest.json"> <link rel="manifest" href="/web-app-manifest.json">

View File

@@ -1,6 +1,6 @@
{ {
"name": "marco", "name": "marco",
"version": "1.8.3", "version": "1.11.2",
"private": true, "private": true,
"description": "Unhosted maps app", "description": "Unhosted maps app",
"repository": { "repository": {
@@ -34,6 +34,7 @@
"lint:js:fix": "eslint . --fix", "lint:js:fix": "eslint . --fix",
"start": "vite", "start": "vite",
"test": "vite build --mode development && testem ci --port 0", "test": "vite build --mode development && testem ci --port 0",
"preversion": "pnpm test",
"version": "pnpm build && git add release/" "version": "pnpm build && git add release/"
}, },
"devDependencies": { "devDependencies": {
@@ -50,7 +51,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": "link:vendor/remotestorage-module-places", "@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",

18
pnpm-lock.yaml generated
View File

@@ -52,8 +52,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: link:vendor/remotestorage-module-places specifier: 1.x
version: link:vendor/remotestorage-module-places 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)
@@ -1377,6 +1377,9 @@ 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.0.0':
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==}
engines: {node: '>=14.0.0'} engines: {node: '>=14.0.0'}
@@ -5180,6 +5183,10 @@ packages:
engines: {node: '>=0.8.0'} engines: {node: '>=0.8.0'}
hasBin: true hasBin: true
ulid@3.0.2:
resolution: {integrity: sha512-yu26mwteFYzBAot7KVMqFGCVpsF6g8wXfJzQUHvu1no3+rRRSFcSV2nKeYvNPLD2J4b08jYBDhHUjeH0ygIl9w==}
hasBin: true
underscore.string@3.3.6: underscore.string@3.3.6:
resolution: {integrity: sha512-VoC83HWXmCrF6rgkyxS9GHv8W9Q5nhMKho+OadDJGzL2oDYbYEppBaCMH6pFlwLeqj2QS+hhkw2kpXkSdD1JxQ==} resolution: {integrity: sha512-VoC83HWXmCrF6rgkyxS9GHv8W9Q5nhMKho+OadDJGzL2oDYbYEppBaCMH6pFlwLeqj2QS+hhkw2kpXkSdD1JxQ==}
@@ -6967,6 +6974,11 @@ 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.0.0':
dependencies:
latlon-geohash: 2.0.0
ulid: 3.0.2
'@rollup/plugin-babel@6.1.0(@babel/core@7.28.6)(rollup@4.55.1)': '@rollup/plugin-babel@6.1.0(@babel/core@7.28.6)(rollup@4.55.1)':
dependencies: dependencies:
'@babel/core': 7.28.6 '@babel/core': 7.28.6
@@ -11449,6 +11461,8 @@ snapshots:
uglify-js@3.19.3: uglify-js@3.19.3:
optional: true optional: true
ulid@3.0.2: {}
underscore.string@3.3.6: underscore.string@3.3.6:
dependencies: dependencies:
sprintf-js: 1.1.3 sprintf-js: 1.1.3

View File

@@ -6,7 +6,7 @@
"scope": "/", "scope": "/",
"display": "standalone", "display": "standalone",
"background_color": "#f8f9fa", "background_color": "#f8f9fa",
"theme_color": "#333333", "theme_color": "#2a3743",
"icons": [ "icons": [
{ {
"src": "/icons/icon-192.png", "src": "/icons/icon-192.png",

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

@@ -9,7 +9,7 @@
<!-- App identity --> <!-- App identity -->
<meta name="application-name" content="Marco"> <meta name="application-name" content="Marco">
<meta name="apple-mobile-web-app-title" content="Marco"> <meta name="apple-mobile-web-app-title" content="Marco">
<meta name="theme-color" content="#333333"> <meta name="theme-color" content="#2a3743">
<!-- PWA Manifest --> <!-- PWA Manifest -->
<link rel="manifest" href="/web-app-manifest.json"> <link rel="manifest" href="/web-app-manifest.json">
@@ -26,8 +26,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-UPJ86bLK.js"></script> <script type="module" crossorigin src="/assets/main-CYFdUlXN.js"></script>
<link rel="stylesheet" crossorigin href="/assets/main-BcLOwTwc.css"> <link rel="stylesheet" crossorigin href="/assets/main-D53xPL_H.css">
</head> </head>
<body> <body>
</body> </body>

View File

@@ -6,7 +6,7 @@
"scope": "/", "scope": "/",
"display": "standalone", "display": "standalone",
"background_color": "#f8f9fa", "background_color": "#f8f9fa",
"theme_color": "#333333", "theme_color": "#2a3743",
"icons": [ "icons": [
{ {
"src": "/icons/icon-192.png", "src": "/icons/icon-192.png",

View File

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

View File

@@ -1 +0,0 @@
/home/basti/src/remotestorage/modules/remotestorage-module-places