Compare commits
71 Commits
41d61be42e
...
v1.12.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
9e03426b2e
|
|||
|
ecbf77c573
|
|||
|
703a5e8de0
|
|||
|
b3c733769c
|
|||
|
60b2548efd
|
|||
|
2e632658ad
|
|||
|
845be96b71
|
|||
|
9ac4273fae
|
|||
|
3a825c3d6c
|
|||
|
a6ca362876
|
|||
|
95e9c621a5
|
|||
|
e980431c17
|
|||
|
4fdf2e2fb6
|
|||
|
de1b162ee9
|
|||
|
1df77c2045
|
|||
|
eb1445b749
|
|||
|
316a38dbf8
|
|||
|
7bcb572dbf
|
|||
|
d827fe263b
|
|||
|
1926e2b20c
|
|||
|
df1f32d8bd
|
|||
|
aa058bd7a3
|
|||
|
361a826e4f
|
|||
|
ff01d54fdd
|
|||
|
f73677139d
|
|||
|
8135695bba
|
|||
|
8217e85836
|
|||
|
d9645d1a8c
|
|||
|
688e8eda8d
|
|||
|
323aab8256
|
|||
|
ecb3fe4b5a
|
|||
|
43b2700465
|
|||
|
00454c8fab
|
|||
|
bf12305600
|
|||
|
2734f08608
|
|||
|
2aa59f9384
|
|||
|
bcf8ca4255
|
|||
|
20f63065ad
|
|||
|
39a7ec3595
|
|||
| 32dfa3a30f | |||
|
64ccc694d3
|
|||
|
87e2380ef6
|
|||
| 66c31b19f1 | |||
|
55aecbd699
|
|||
|
ccaa56b78f
|
|||
|
d30375707a
|
|||
|
53300b92f5
|
|||
|
c37f794eea
|
|||
|
4bc92bb7cc
|
|||
|
9f48d7b264
|
|||
| bbd3bf47c6 | |||
|
59e3d91071
|
|||
|
348b721876
|
|||
|
3d982a6a7c
|
|||
|
0af9d9f16d
|
|||
|
a0f132ec64
|
|||
|
925f26ae5d
|
|||
|
58bb8831f3
|
|||
|
585837cae7
|
|||
|
42c5282844
|
|||
|
8a0603c65e
|
|||
|
8e3187f38d
|
|||
|
a73e5cda6a
|
|||
|
0212fa359b
|
|||
|
8c58a76030
|
|||
|
a10f87290a
|
|||
|
e7b3b72e2f
|
|||
|
399ad1822d
|
|||
|
104a742543
|
|||
|
a8dc4c81e4
|
|||
|
156280950f
|
@@ -18,15 +18,15 @@ jobs:
|
||||
timeout-minutes: 10
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v4
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10
|
||||
- name: Install Node
|
||||
uses: actions/setup-node@v6
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
|
||||
- name: Install Dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
- name: Lint
|
||||
@@ -35,18 +35,16 @@ jobs:
|
||||
test:
|
||||
name: "Test"
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: cypress/browsers:node-22.19.0-chrome-139.0.7258.154-1-ff-142.0.1-edge-139.0.3405.125-1
|
||||
timeout-minutes: 10
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v4
|
||||
- uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10
|
||||
- name: Install Node
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
|
||||
- name: Install Dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
- name: Run Tests
|
||||
@@ -1,3 +1,7 @@
|
||||
export default {
|
||||
extends: ['stylelint-config-standard'],
|
||||
rules: {
|
||||
'no-descending-specificity': null,
|
||||
'property-no-vendor-prefix': null,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Project Status: Marco
|
||||
|
||||
**Last Updated:** Mon Jan 26 2026
|
||||
**Last Updated:** Tue Feb 24 2026
|
||||
|
||||
## Project Context
|
||||
|
||||
@@ -15,7 +15,6 @@ We are building **Marco**, a decentralized maps application using **Ember.js** (
|
||||
- Implemented a hybrid click handler:
|
||||
- Detects clicks on visual vector tiles.
|
||||
- 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.
|
||||
- **Optimization:** Added **10px hit tolerance** for easier tapping on mobile devices.
|
||||
- **Visuals:** Increased bookmark marker size (Radius 9px) and added a subtle drop shadow.
|
||||
@@ -37,6 +36,12 @@ We are building **Marco**, a decentralized maps application using **Ember.js** (
|
||||
- **Persistence:** Saves and restores map center and zoom level using `localStorage` (key: `marco:map-view`).
|
||||
- **Controls:** Enabled standard OpenLayers Rotate control (re-north) and custom Locate control.
|
||||
- **Pin Animation:** Selected pins are highlighted with a custom **Red Pin** overlay that drops in with an animation. The center dot is styled as a solid dark red circle (`#b31412`).
|
||||
- **Smart Zoom:** Implemented `zoomToBbox` to automatically fit complex geometries (ways/relations) within the visible viewport.
|
||||
- **Dynamic Padding:** Calculates padding based on active UI elements (Sidebar on Desktop, Bottom Sheet on Mobile) to ensure the geometry is perfectly centered in the _visible_ map area.
|
||||
- **Data Processing:** `OsmService` now calculates bounding boxes for ways and relations by aggregating member node coordinates.
|
||||
- **Geometry Rendering:**
|
||||
- **Outlines:** Implemented distinct blue outlines for selected OSM `ways` (Polygons) and `relations` (MultiLineStrings/Polygons) to clearly visualize boundaries.
|
||||
- **Data Fetching:** Enhanced routing to fetch full geometry data on-demand if the initial search result (e.g., from Photon) lacks it, ensuring outlines are always available.
|
||||
|
||||
### 2. RemoteStorage Module (`@remotestorage/module-places`)
|
||||
|
||||
@@ -58,6 +63,7 @@ We are building **Marco**, a decentralized maps application using **Ember.js** (
|
||||
- `osm.js`: Fetches nearby POIs from Overpass API.
|
||||
- **Configurable:** Now supports dynamic API endpoints via `SettingsService`.
|
||||
- **Reliability:** Implemented `fetchWithRetry` to handle HTTP 504/502/503 timeouts and 429 rate limits, in addition to network errors.
|
||||
- **Caching:** Implemented in-memory cache for repeated `getNearbyPois` requests (same lat/lon/radius) to enable instant "Back" navigation.
|
||||
- `settings.js`: Manages user preferences (currently Overpass API provider) persisted to `localStorage`.
|
||||
- **UI Components:**
|
||||
- `places-sidebar.gjs`: Displays a list of nearby POIs.
|
||||
@@ -73,45 +79,75 @@ We are building **Marco**, a decentralized maps application using **Ember.js** (
|
||||
- **Geo Utils:**
|
||||
- `app/utils/geo.js`: Haversine distance calculations.
|
||||
- `app/utils/geohash-coverage.js`: Logic to calculate required 4-char geohash prefixes for a given bounding box.
|
||||
- **Format Utils:**
|
||||
- `app/utils/format-text.js` & `humanize-osm-tag` helper: Standardized logic (Title Case, space replacement) for displaying OSM tags like `guest_house` -> "Guest House".
|
||||
- **Tag refinement:** Improved logic for handling generic tags (e.g., `building=yes`). The UI now intelligently displays the key ("Building") instead of the value ("Yes") for better readability.
|
||||
- **Localization:** Added basic `navigator.languages` support to `getLocalizedName` for preferring local names when available.
|
||||
- **Build & DevOps:**
|
||||
- **Icon Generation:** Added `build:icons` script using `magick` and `rsvg-convert` to automate PNG generation from SVG.
|
||||
- **Dependencies:** Documented system requirements (ImageMagick, librsvg) in `README.md`.
|
||||
- **Ember CLI:** Added as dev dependency to support generator commands.
|
||||
- **License:** Added AGPLv3 license.
|
||||
|
||||
### 4. Routing & 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.
|
||||
- **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`.
|
||||
|
||||
### 6. Search Functionality
|
||||
|
||||
- **Provider:** Integrated **Photon API** (by Komoot) via `app/services/photon.js` for high-quality, typo-tolerant OpenStreetMap search.
|
||||
- **UI:** `SearchBoxComponent` implements a responsive search bar with instant autocomplete.
|
||||
- **Debounced Input:** 300ms delay to prevent excessive API calls.
|
||||
- **Location Bias:** Automatically biases search results towards the current map center to show relevant local places first.
|
||||
- **Direct Navigation:** Selecting a result with a valid OSM ID navigates directly to the specific place details (`/place/osm:type:id`).
|
||||
- **Resilience:** Implemented retry logic (exponential backoff/fixed delay) for network errors and rate limits (429).
|
||||
- **Data Normalization:** Search results are normalized to match the internal POI schema, ensuring consistent rendering across Search and Map views.
|
||||
|
||||
## Current State
|
||||
|
||||
- **Repo:** The app runs via `pnpm start`.
|
||||
- **Workflow:**
|
||||
1. User pans map -> `moveend` triggers `storage.loadPlacesInBounds`.
|
||||
2. User clicks map -> "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.
|
||||
2. User clicks map -> Route transition to `/search` -> "Pulse" animation -> hybrid hit detection (Visual Tile vs Overpass).
|
||||
3. **Navigation:**
|
||||
- If direct match: Redirect to `/place/:id`.
|
||||
- If multiple results: Show `/search` list view.
|
||||
4. Sidebar displays details via `<PlaceDetails>` component (Bottom sheet on mobile).
|
||||
5. User clicks "Save Bookmark" -> Stores JSON in RemoteStorage.
|
||||
6. RemoteStorage change event -> Debounced reload updates the map reactive-ly.
|
||||
5. **Creation:** User clicks "Create Place" -> Enters creation mode (crosshair) -> Positions map -> Enters details -> Save.
|
||||
6. **Persistence:** RemoteStorage change event -> Debounced reload updates the map reactive-ly.
|
||||
7. **Editing:** User can edit the Title and Description of saved bookmarks via an "Edit" button in the details view.
|
||||
8. **Settings:** User can change the Overpass API provider via the new Settings menu.
|
||||
|
||||
## Files Currently in Focus
|
||||
|
||||
- `app/styles/app.css`: Mobile CSS fixes (font sizes, control positioning).
|
||||
- `package.json`: New scripts and dependencies.
|
||||
- `README.md`: Updated documentation.
|
||||
- `app/services/osm.js`
|
||||
- `app/components/map.gjs`
|
||||
- `app/routes/place.js`
|
||||
- `app/utils/osm.js`
|
||||
|
||||
## Next Steps & Pending Tasks
|
||||
|
||||
1. **Mobile Polish:** Verify "Locate Me" animation on iOS Safari.
|
||||
2. **Collections/Lists:** Implement ability to organize bookmarks into lists/collections.
|
||||
3. **Linting & Code Quality:** Fix remaining CSS errors, remove inline styles in `map.gjs`, and address unused variables/runloop usage.
|
||||
4. **Performance:** Monitor performance with large datasets (thousands of bookmarks).
|
||||
5. **Testing:** Add automated tests for the geohash coverage, retry logic, and new editing features.
|
||||
1. **Linting & Code Quality:** Fix remaining CSS errors and address unused variables/runloop usage.
|
||||
2. **Testing:** Add automated tests for the geohash coverage, retry logic, and new editing features.
|
||||
3. **Performance:** Monitor performance with large datasets (thousands of bookmarks).
|
||||
|
||||
## Technical Constraints
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { action } from '@ember/object';
|
||||
import { on } from '@ember/modifier';
|
||||
import Icon from '#components/icon';
|
||||
import UserMenu from '#components/user-menu';
|
||||
import SearchBox from '#components/search-box';
|
||||
|
||||
export default class AppHeaderComponent extends Component {
|
||||
@service storage;
|
||||
@@ -23,20 +24,13 @@ export default class AppHeaderComponent extends Component {
|
||||
<template>
|
||||
<header class="app-header">
|
||||
<div class="header-left">
|
||||
<button
|
||||
class="icon-btn"
|
||||
type="button"
|
||||
aria-label="Menu"
|
||||
{{on "click" @onToggleMenu}}
|
||||
>
|
||||
<Icon @name="menu" @size={{24}} @color="#333" />
|
||||
</button>
|
||||
<SearchBox @onToggleMenu={{@onToggleMenu}} />
|
||||
</div>
|
||||
|
||||
<div class="header-right">
|
||||
<div class="user-menu-container">
|
||||
<button
|
||||
class="user-btn"
|
||||
class="user-btn btn-press"
|
||||
type="button"
|
||||
aria-label="User Menu"
|
||||
{{on "click" this.toggleUserMenu}}
|
||||
|
||||
@@ -15,12 +15,15 @@ import mapPin from 'feather-icons/dist/icons/map-pin.svg?raw';
|
||||
import menu from 'feather-icons/dist/icons/menu.svg?raw';
|
||||
import navigation from 'feather-icons/dist/icons/navigation.svg?raw';
|
||||
import phone from 'feather-icons/dist/icons/phone.svg?raw';
|
||||
import plus from 'feather-icons/dist/icons/plus.svg?raw';
|
||||
import server from 'feather-icons/dist/icons/server.svg?raw';
|
||||
import search from 'feather-icons/dist/icons/search.svg?raw';
|
||||
import settings from 'feather-icons/dist/icons/settings.svg?raw';
|
||||
import target from 'feather-icons/dist/icons/target.svg?raw';
|
||||
import user from 'feather-icons/dist/icons/user.svg?raw';
|
||||
import x from 'feather-icons/dist/icons/x.svg?raw';
|
||||
import zap from 'feather-icons/dist/icons/zap.svg?raw';
|
||||
import wikipedia from '../icons/wikipedia.svg?raw';
|
||||
|
||||
const ICONS = {
|
||||
'arrow-left': arrowLeft,
|
||||
@@ -37,10 +40,13 @@ const ICONS = {
|
||||
menu,
|
||||
navigation,
|
||||
phone,
|
||||
plus,
|
||||
server,
|
||||
search,
|
||||
settings,
|
||||
target,
|
||||
user,
|
||||
wikipedia,
|
||||
x,
|
||||
zap,
|
||||
};
|
||||
@@ -59,7 +65,9 @@ export default class IconComponent extends Component {
|
||||
}
|
||||
|
||||
get style() {
|
||||
return `width:${this.size}px;height:${this.size}px;color:${this.color}`;
|
||||
return htmlSafe(
|
||||
`width:${this.size}px;height:${this.size}px;color:${this.color}`
|
||||
);
|
||||
}
|
||||
|
||||
get title() {
|
||||
@@ -68,7 +76,11 @@ export default class IconComponent extends Component {
|
||||
|
||||
<template>
|
||||
{{#if this.svg}}
|
||||
<span class="icon" style={{this.style}} title={{this.title}}>
|
||||
<span
|
||||
class="icon {{if @filled 'icon-filled'}}"
|
||||
style={{this.style}}
|
||||
title={{this.title}}
|
||||
>
|
||||
{{htmlSafe this.svg}}
|
||||
</span>
|
||||
{{/if}}
|
||||
|
||||
@@ -4,6 +4,8 @@ import { modifier } from 'ember-modifier';
|
||||
import 'ol/ol.css';
|
||||
import Map from 'ol/Map.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 { fromLonLat, toLonLat, getPointResolution } from 'ol/proj.js';
|
||||
import Overlay from 'ol/Overlay.js';
|
||||
@@ -11,29 +13,51 @@ import LayerGroup from 'ol/layer/Group.js';
|
||||
import VectorLayer from 'ol/layer/Vector.js';
|
||||
import VectorSource from 'ol/source/Vector.js';
|
||||
import Feature from 'ol/Feature.js';
|
||||
import GeoJSON from 'ol/format/GeoJSON.js';
|
||||
import Point from 'ol/geom/Point.js';
|
||||
import Geolocation from 'ol/Geolocation.js';
|
||||
import { Style, Circle, Fill, Stroke } from 'ol/style.js';
|
||||
import { apply } from 'ol-mapbox-style';
|
||||
import { getDistance } from '../utils/geo';
|
||||
|
||||
export default class MapComponent extends Component {
|
||||
@service osm;
|
||||
@service storage;
|
||||
@service mapUi;
|
||||
@service router;
|
||||
@service settings;
|
||||
|
||||
mapInstance;
|
||||
bookmarkSource;
|
||||
selectedShapeSource;
|
||||
searchOverlay;
|
||||
searchOverlayElement;
|
||||
selectedPinOverlay;
|
||||
selectedPinElement;
|
||||
crosshairElement;
|
||||
crosshairOverlay;
|
||||
ignoreNextMapClick = false;
|
||||
|
||||
setupMap = modifier((element) => {
|
||||
if (this.mapInstance) return;
|
||||
|
||||
const openfreemap = new LayerGroup();
|
||||
|
||||
// Create a vector source and layer for the selected shape (outline)
|
||||
this.selectedShapeSource = new VectorSource();
|
||||
const selectedShapeLayer = new VectorLayer({
|
||||
source: this.selectedShapeSource,
|
||||
style: new Style({
|
||||
stroke: new Stroke({
|
||||
color: '#3388ff',
|
||||
width: 4,
|
||||
}),
|
||||
fill: new Fill({
|
||||
color: 'rgba(51, 136, 255, 0.1)',
|
||||
}),
|
||||
}),
|
||||
zIndex: 5, // Below bookmarks (10) but above tiles
|
||||
});
|
||||
|
||||
// Create a vector source and layer for bookmarks
|
||||
this.bookmarkSource = new VectorSource();
|
||||
const bookmarkLayer = new VectorLayer({
|
||||
@@ -63,6 +87,7 @@ export default class MapComponent extends Component {
|
||||
// Default view settings
|
||||
let center = [14.21683569, 27.060114248];
|
||||
let zoom = 2.661;
|
||||
let restoredFromStorage = false;
|
||||
|
||||
// Try to restore from localStorage
|
||||
try {
|
||||
@@ -77,6 +102,7 @@ export default class MapComponent extends Component {
|
||||
) {
|
||||
center = parsed.center;
|
||||
zoom = parsed.zoom;
|
||||
restoredFromStorage = true;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -91,15 +117,22 @@ export default class MapComponent extends Component {
|
||||
|
||||
this.mapInstance = new Map({
|
||||
target: element,
|
||||
layers: [openfreemap, bookmarkLayer],
|
||||
layers: [openfreemap, selectedShapeLayer, bookmarkLayer],
|
||||
view: view,
|
||||
controls: defaultControls({
|
||||
zoom: false,
|
||||
zoom: true,
|
||||
rotate: true,
|
||||
attribution: true,
|
||||
}),
|
||||
interactions: defaultInteractions({
|
||||
dragPan: false, // Disable default DragPan to add a custom one
|
||||
}),
|
||||
});
|
||||
|
||||
// Initialize the UI service with the map center
|
||||
const initialCenter = toLonLat(view.getCenter());
|
||||
this.mapUi.updateCenter(initialCenter[1], initialCenter[0]);
|
||||
|
||||
apply(this.mapInstance, 'https://tiles.openfreemap.org/styles/liberty');
|
||||
|
||||
this.searchOverlayElement = document.createElement('div');
|
||||
@@ -112,18 +145,13 @@ export default class MapComponent extends Component {
|
||||
this.mapInstance.addOverlay(this.searchOverlay);
|
||||
|
||||
// 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.className = 'selected-pin-container';
|
||||
|
||||
// Create the icon structure inside
|
||||
const pinIcon = document.createElement('div');
|
||||
pinIcon.className = 'selected-pin';
|
||||
// We can't use the Glimmer <Icon> component easily inside a raw DOM element created here.
|
||||
// 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:
|
||||
// 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>`;
|
||||
|
||||
const pinShadow = document.createElement('div');
|
||||
@@ -134,11 +162,22 @@ export default class MapComponent extends Component {
|
||||
|
||||
this.selectedPinOverlay = new Overlay({
|
||||
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
|
||||
});
|
||||
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
|
||||
this.locationOverlayElement = document.createElement('div');
|
||||
this.locationOverlayElement.className = 'search-pulse blue';
|
||||
@@ -149,6 +188,18 @@ export default class MapComponent extends Component {
|
||||
});
|
||||
this.mapInstance.addOverlay(this.locationOverlay);
|
||||
|
||||
// Track search box focus state on pointer down to handle race conditions
|
||||
// The blur event fires before click, so we need to capture state here
|
||||
element.addEventListener(
|
||||
'pointerdown',
|
||||
() => {
|
||||
if (this.mapUi.searchBoxHasFocus) {
|
||||
this.ignoreNextMapClick = true;
|
||||
}
|
||||
},
|
||||
true
|
||||
);
|
||||
|
||||
// Geolocation Setup
|
||||
const geolocation = new Geolocation({
|
||||
trackingOptions: {
|
||||
@@ -226,6 +277,7 @@ export default class MapComponent extends Component {
|
||||
const coordinates = geolocation.getPosition();
|
||||
const accuracyGeometry = geolocation.getAccuracyGeometry();
|
||||
const accuracy = geolocation.getAccuracy();
|
||||
console.debug('Geolocation change:', { coordinates, accuracy });
|
||||
|
||||
if (!coordinates) return;
|
||||
|
||||
@@ -281,7 +333,7 @@ export default class MapComponent extends Component {
|
||||
};
|
||||
|
||||
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);
|
||||
} else {
|
||||
viewOptions.zoom = 16;
|
||||
@@ -290,7 +342,8 @@ export default class MapComponent extends Component {
|
||||
this.mapInstance.getView().animate(viewOptions);
|
||||
};
|
||||
|
||||
locateBtn.addEventListener('click', () => {
|
||||
const startLocating = () => {
|
||||
console.debug('Getting current geolocation...');
|
||||
// 1. Clear any previous session
|
||||
stopLocating();
|
||||
|
||||
@@ -314,7 +367,9 @@ export default class MapComponent extends Component {
|
||||
locateTimeout = setTimeout(() => {
|
||||
stopLocating();
|
||||
}, 10000);
|
||||
});
|
||||
};
|
||||
|
||||
locateBtn.addEventListener('click', startLocating);
|
||||
|
||||
const locateControl = new Control({
|
||||
element: locateElement,
|
||||
@@ -323,6 +378,11 @@ export default class MapComponent extends Component {
|
||||
this.mapInstance.addLayer(geolocationLayer);
|
||||
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);
|
||||
|
||||
// Load places when map moves
|
||||
@@ -337,16 +397,45 @@ export default class MapComponent extends Component {
|
||||
this.mapInstance.getTarget().style.cursor = hit ? 'pointer' : '';
|
||||
});
|
||||
|
||||
// Load initial bookmarks
|
||||
this.storage.rs.on('ready', () => {
|
||||
// Initial load based on current view
|
||||
this.handleMapMove();
|
||||
});
|
||||
});
|
||||
|
||||
// Listen for remote storage changes
|
||||
// this.storage.rs.on('connected', () => {
|
||||
// this.loadBookmarks();
|
||||
// });
|
||||
updateInteractions = modifier(() => {
|
||||
if (!this.mapInstance) return;
|
||||
|
||||
// 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)
|
||||
@@ -355,6 +444,11 @@ export default class MapComponent extends Component {
|
||||
|
||||
if (!this.selectedPinOverlay || !this.selectedPinElement) return;
|
||||
|
||||
// Clear any previous shape
|
||||
if (this.selectedShapeSource) {
|
||||
this.selectedShapeSource.clear();
|
||||
}
|
||||
|
||||
if (selected && selected.lat && selected.lon) {
|
||||
const coords = fromLonLat([selected.lon, selected.lat]);
|
||||
this.selectedPinOverlay.setPosition(coords);
|
||||
@@ -365,7 +459,23 @@ export default class MapComponent extends Component {
|
||||
void this.selectedPinElement.offsetWidth;
|
||||
this.selectedPinElement.classList.add('active');
|
||||
|
||||
this.handlePinVisibility(coords);
|
||||
// Draw GeoJSON shape if available
|
||||
if (selected.geojson && this.selectedShapeSource) {
|
||||
try {
|
||||
const feature = new GeoJSON().readFeature(selected.geojson, {
|
||||
featureProjection: 'EPSG:3857',
|
||||
});
|
||||
this.selectedShapeSource.addFeature(feature);
|
||||
} catch (e) {
|
||||
console.warn('Failed to render selected place shape:', e);
|
||||
}
|
||||
}
|
||||
|
||||
if (selected.bbox) {
|
||||
this.zoomToBbox(selected.bbox);
|
||||
} else {
|
||||
this.handlePinVisibility(coords);
|
||||
}
|
||||
} else {
|
||||
this.selectedPinElement.classList.remove('active');
|
||||
// Hide it effectively by moving it away or just relying on display:none in CSS
|
||||
@@ -373,6 +483,55 @@ export default class MapComponent extends Component {
|
||||
}
|
||||
});
|
||||
|
||||
zoomToBbox(bbox) {
|
||||
if (!this.mapInstance || !bbox) return;
|
||||
|
||||
const view = this.mapInstance.getView();
|
||||
const size = this.mapInstance.getSize();
|
||||
|
||||
// Convert bbox to extent: [minx, miny, maxx, maxy]
|
||||
const min = fromLonLat([bbox.minLon, bbox.minLat]);
|
||||
const max = fromLonLat([bbox.maxLon, bbox.maxLat]);
|
||||
const extent = [...min, ...max];
|
||||
|
||||
// Default padding for full screen: 15% on all sides (70% visible)
|
||||
let padding = [
|
||||
size[1] * 0.15, // Top
|
||||
size[0] * 0.15, // Right
|
||||
size[1] * 0.15, // Bottom
|
||||
size[0] * 0.15, // Left
|
||||
];
|
||||
|
||||
// Mobile: Bottom sheet covers 50% of the screen height
|
||||
if (size[0] <= 768) {
|
||||
// We want the geometry to be centered in the top 50% of the screen.
|
||||
// Top padding: 15% of the VISIBLE height (size[1] * 0.5)
|
||||
const visibleHeight = size[1] * 0.5;
|
||||
const topPadding = visibleHeight * 0.15;
|
||||
const bottomPadding = size[1] * 0.5 + visibleHeight * 0.15; // Sheet + padding
|
||||
|
||||
padding[0] = topPadding;
|
||||
padding[2] = bottomPadding;
|
||||
}
|
||||
// Desktop: Sidebar covers left side (approx 400px)
|
||||
else if (this.args.isSidebarOpen) {
|
||||
const sidebarWidth = 400;
|
||||
const visibleWidth = size[0] - sidebarWidth;
|
||||
|
||||
// Left padding: Sidebar + 15% of visible width
|
||||
padding[3] = sidebarWidth + visibleWidth * 0.15;
|
||||
// Right padding: 15% of visible width
|
||||
padding[1] = visibleWidth * 0.15;
|
||||
}
|
||||
|
||||
view.fit(extent, {
|
||||
padding: padding,
|
||||
duration: 1000,
|
||||
easing: (t) => t * (2 - t),
|
||||
maxZoom: 19,
|
||||
});
|
||||
}
|
||||
|
||||
handlePinVisibility(coords) {
|
||||
if (!this.mapInstance) return;
|
||||
|
||||
@@ -415,12 +574,9 @@ export default class MapComponent extends Component {
|
||||
const offsetPixels = height * 0.25; // Distance from desired pin pos to map center
|
||||
const offsetMapUnits = offsetPixels * resolution;
|
||||
|
||||
// Shift center SOUTH (decrease Y)
|
||||
// Shift center SOUTH (decrease Y).
|
||||
// Note: In Web Mercator (EPSG:3857), Y increases North.
|
||||
// So to look "lower", we decrease Y? No wait.
|
||||
// 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).
|
||||
// To move the camera South (Lower Y), we subtract.
|
||||
targetCenter = [coords[0], coords[1] - offsetMapUnits];
|
||||
}
|
||||
|
||||
@@ -468,7 +624,6 @@ export default class MapComponent extends Component {
|
||||
}
|
||||
}
|
||||
|
||||
// Re-fetch bookmarks when the version changes (triggered by parent action or service)
|
||||
updateBookmarks = modifier(() => {
|
||||
// Depend on the tracked storage.placesInView to automatically update when they change
|
||||
const places = this.storage.placesInView;
|
||||
@@ -480,15 +635,10 @@ export default class MapComponent extends Component {
|
||||
if (!this.bookmarkSource) return;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// 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.
|
||||
|
||||
this.bookmarkSource.clear();
|
||||
|
||||
if (places && Array.isArray(places)) {
|
||||
@@ -510,9 +660,131 @@ 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 () => {
|
||||
if (!this.mapInstance) return;
|
||||
|
||||
const view = this.mapInstance.getView();
|
||||
const center = toLonLat(view.getCenter());
|
||||
this.mapUi.updateCenter(center[1], center[0]);
|
||||
|
||||
// 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 extent = this.mapInstance.getView().calculateExtent(size);
|
||||
const [minLon, minLat] = toLonLat([extent[0], extent[1]]);
|
||||
@@ -540,13 +812,17 @@ export default class MapComponent extends Component {
|
||||
};
|
||||
|
||||
handleMapClick = async (event) => {
|
||||
if (this.ignoreNextMapClick) {
|
||||
this.ignoreNextMapClick = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if user clicked on a rendered feature (POI or Bookmark) FIRST
|
||||
const features = this.mapInstance.getFeaturesAtPixel(event.pixel, {
|
||||
hitTolerance: 10,
|
||||
});
|
||||
let clickedBookmark = null;
|
||||
let selectedFeatureName = null;
|
||||
let selectedFeatureType = null;
|
||||
|
||||
if (features && features.length > 0) {
|
||||
console.debug(`Found ${features.length} features in map layer:`);
|
||||
@@ -561,7 +837,6 @@ export default class MapComponent extends Component {
|
||||
const props = features[0].getProperties();
|
||||
if (props.name) {
|
||||
selectedFeatureName = props.name;
|
||||
selectedFeatureType = props.class || props.subclass;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -569,13 +844,11 @@ export default class MapComponent extends Component {
|
||||
if (this.args.isSidebarOpen) {
|
||||
// If it's a bookmark, we allow "switching" to it even if sidebar is open
|
||||
if (clickedBookmark) {
|
||||
console.log(
|
||||
console.debug(
|
||||
'Clicked bookmark while sidebar open (switching):',
|
||||
clickedBookmark
|
||||
);
|
||||
if (this.args.onPlacesFound) {
|
||||
this.args.onPlacesFound([], clickedBookmark);
|
||||
}
|
||||
this.router.transitionTo('place', clickedBookmark);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -588,10 +861,15 @@ export default class MapComponent extends Component {
|
||||
|
||||
// Normal behavior (sidebar is closed)
|
||||
if (clickedBookmark) {
|
||||
console.log('Clicked bookmark:', clickedBookmark);
|
||||
if (this.args.onPlacesFound) {
|
||||
this.args.onPlacesFound([], clickedBookmark);
|
||||
}
|
||||
console.debug('Clicked bookmark:', clickedBookmark);
|
||||
this.router.transitionTo('place', 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;
|
||||
}
|
||||
|
||||
@@ -615,84 +893,31 @@ export default class MapComponent extends Component {
|
||||
this.searchOverlayElement.style.width = `${diameterInPixels}px`;
|
||||
this.searchOverlayElement.style.height = `${diameterInPixels}px`;
|
||||
this.searchOverlay.setPosition(event.coordinate);
|
||||
this.searchOverlayElement.classList.add('active');
|
||||
}
|
||||
|
||||
// 2. Fetch authoritative data via Overpass
|
||||
try {
|
||||
let pois = await this.osm.getNearbyPois(lat, lon, searchRadius);
|
||||
// Start Search State
|
||||
this.mapUi.startSearch();
|
||||
|
||||
// Sort by distance from click
|
||||
pois = pois
|
||||
.map((p) => {
|
||||
// p is already normalized by service, so lat/lon are at top level
|
||||
return {
|
||||
...p,
|
||||
_distance: getDistance(lat, lon, p.lat, p.lon),
|
||||
};
|
||||
})
|
||||
.sort((a, b) => a._distance - b._distance);
|
||||
// Transition to Search Route
|
||||
const queryParams = {
|
||||
lat: lat.toFixed(6),
|
||||
lon: lon.toFixed(6),
|
||||
q: null, // Clear q to force spatial search
|
||||
selected: selectedFeatureName || null,
|
||||
};
|
||||
|
||||
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>
|
||||
<div
|
||||
class="map-container"
|
||||
class="map-container {{if @isSidebarOpen 'sidebar-open'}}"
|
||||
{{this.setupMap}}
|
||||
{{this.updateInteractions}}
|
||||
{{this.updateBookmarks}}
|
||||
{{this.updateSelectedPin}}
|
||||
{{this.syncPulse}}
|
||||
{{this.syncCreationMode}}
|
||||
></div>
|
||||
</template>
|
||||
}
|
||||
|
||||
@@ -1,21 +1,17 @@
|
||||
import Component from '@glimmer/component';
|
||||
import { fn } from '@ember/helper';
|
||||
import { on } from '@ember/modifier';
|
||||
import capitalize from '../helpers/capitalize';
|
||||
import { htmlSafe } from '@ember/template';
|
||||
import { humanizeOsmTag } from '../utils/format-text';
|
||||
import { getLocalizedName, getPlaceType } from '../utils/osm';
|
||||
import Icon from '../components/icon';
|
||||
import PlaceEditForm from './place-edit-form';
|
||||
|
||||
import { tracked } from '@glimmer/tracking';
|
||||
import { action } from '@ember/object';
|
||||
|
||||
export default class PlaceDetails extends Component {
|
||||
@tracked isEditing = false;
|
||||
@tracked editTitle = '';
|
||||
@tracked editDescription = '';
|
||||
|
||||
constructor() {
|
||||
super(...arguments);
|
||||
this.resetEditFields();
|
||||
}
|
||||
|
||||
get place() {
|
||||
return this.args.place || {};
|
||||
@@ -26,24 +22,12 @@ export default class PlaceDetails extends Component {
|
||||
}
|
||||
|
||||
get name() {
|
||||
return (
|
||||
this.place.title ||
|
||||
this.tags.name ||
|
||||
this.tags['name:en'] ||
|
||||
'Unnamed Place'
|
||||
);
|
||||
}
|
||||
|
||||
@action
|
||||
resetEditFields() {
|
||||
this.editTitle = this.name;
|
||||
this.editDescription = this.place.description || '';
|
||||
return this.place.title || getLocalizedName(this.tags) || 'Unnamed Place';
|
||||
}
|
||||
|
||||
@action
|
||||
startEditing() {
|
||||
if (!this.place.createdAt) return; // Only allow editing saved places
|
||||
this.resetEditFields();
|
||||
this.isEditing = true;
|
||||
}
|
||||
|
||||
@@ -53,93 +37,135 @@ export default class PlaceDetails extends Component {
|
||||
}
|
||||
|
||||
@action
|
||||
async saveChanges(event) {
|
||||
event.preventDefault();
|
||||
async saveChanges(changes) {
|
||||
if (this.args.onSave) {
|
||||
await this.args.onSave({
|
||||
...this.place,
|
||||
title: this.editTitle,
|
||||
description: this.editDescription,
|
||||
...changes,
|
||||
});
|
||||
}
|
||||
this.isEditing = false;
|
||||
}
|
||||
|
||||
@action
|
||||
updateTitle(e) {
|
||||
this.editTitle = e.target.value;
|
||||
}
|
||||
|
||||
@action
|
||||
updateDescription(e) {
|
||||
this.editDescription = e.target.value;
|
||||
}
|
||||
|
||||
get type() {
|
||||
return (
|
||||
this.tags.amenity ||
|
||||
this.tags.shop ||
|
||||
this.tags.tourism ||
|
||||
this.tags.leisure ||
|
||||
this.tags.historic ||
|
||||
'Point of Interest'
|
||||
);
|
||||
return getPlaceType(this.tags);
|
||||
}
|
||||
|
||||
get address() {
|
||||
const t = this.tags;
|
||||
const parts = [];
|
||||
|
||||
// Helper to get value from multiple keys
|
||||
const get = (...keys) => {
|
||||
for (const k of keys) {
|
||||
if (t[k]) return t[k];
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
// Street + Number
|
||||
if (t['addr:street']) {
|
||||
let street = t['addr:street'];
|
||||
if (t['addr:housenumber']) {
|
||||
street += ` ${t['addr:housenumber']}`;
|
||||
let street = get('addr:street', 'street');
|
||||
const number = get('addr:housenumber', 'housenumber');
|
||||
|
||||
if (street) {
|
||||
if (number) {
|
||||
street = `${street} ${number}`;
|
||||
}
|
||||
parts.push(street);
|
||||
}
|
||||
|
||||
// Postcode + City
|
||||
if (t['addr:city']) {
|
||||
let city = t['addr:city'];
|
||||
if (t['addr:postcode']) {
|
||||
city = `${t['addr:postcode']} ${city}`;
|
||||
let city = get('addr:city', 'city');
|
||||
const postcode = get('addr:postcode', 'postcode');
|
||||
|
||||
if (city) {
|
||||
if (postcode) {
|
||||
city = `${postcode} ${city}`;
|
||||
}
|
||||
parts.push(city);
|
||||
}
|
||||
|
||||
// State + Country (if not already covered)
|
||||
const state = get('addr:state', 'state');
|
||||
const country = get('addr:country', 'country');
|
||||
|
||||
if (state && state !== city) parts.push(state);
|
||||
if (country) parts.push(country);
|
||||
|
||||
if (parts.length === 0) return null;
|
||||
return parts.join(', ');
|
||||
}
|
||||
|
||||
formatMultiLine(val, type) {
|
||||
if (!val) return null;
|
||||
const parts = val
|
||||
.split(';')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
if (parts.length === 0) return null;
|
||||
|
||||
if (type === 'phone') {
|
||||
return htmlSafe(
|
||||
parts.map((p) => `<a href="tel:${p}">${p}</a>`).join('<br>')
|
||||
);
|
||||
}
|
||||
|
||||
if (type === 'url') {
|
||||
return htmlSafe(
|
||||
parts
|
||||
.map(
|
||||
(url) =>
|
||||
`<a href="${url}" target="_blank" rel="noopener noreferrer">${this.getDomain(
|
||||
url
|
||||
)}</a>`
|
||||
)
|
||||
.join('<br>')
|
||||
);
|
||||
}
|
||||
|
||||
return htmlSafe(parts.join('<br>'));
|
||||
}
|
||||
|
||||
get phone() {
|
||||
return this.tags.phone || this.tags['contact:phone'];
|
||||
const val = this.tags.phone || this.tags['contact:phone'];
|
||||
return this.formatMultiLine(val, 'phone');
|
||||
}
|
||||
|
||||
get website() {
|
||||
return this.place.url || this.tags.website || this.tags['contact:website'];
|
||||
const val =
|
||||
this.place.url || this.tags.website || this.tags['contact:website'];
|
||||
return this.formatMultiLine(val, 'url');
|
||||
}
|
||||
|
||||
get websiteDomain() {
|
||||
const url = new URL(this.website);
|
||||
return url.hostname;
|
||||
getDomain(urlStr) {
|
||||
try {
|
||||
const url = new URL(urlStr);
|
||||
return url.hostname;
|
||||
} catch {
|
||||
return urlStr;
|
||||
}
|
||||
}
|
||||
|
||||
get openingHours() {
|
||||
return this.tags.opening_hours;
|
||||
const val = this.tags.opening_hours;
|
||||
return this.formatMultiLine(val);
|
||||
}
|
||||
|
||||
get cuisine() {
|
||||
if (!this.tags.cuisine) return null;
|
||||
return this.tags.cuisine
|
||||
.split(';')
|
||||
.map((c) => capitalize.compute([c]))
|
||||
.map((c) => c.replace('_', ' '))
|
||||
.map((c) => humanizeOsmTag(c))
|
||||
.join(', ');
|
||||
}
|
||||
|
||||
get wikipedia() {
|
||||
return this.tags.wikipedia;
|
||||
const val = this.tags.wikipedia;
|
||||
if (!val) return null;
|
||||
return val
|
||||
.split(';')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean)[0];
|
||||
}
|
||||
|
||||
get geoLink() {
|
||||
@@ -154,7 +180,7 @@ export default class PlaceDetails extends Component {
|
||||
const lat = this.place.lat;
|
||||
const lon = this.place.lon;
|
||||
if (!lat || !lon) return '';
|
||||
return `${lat}, ${lon}`;
|
||||
return `${Number(lat).toFixed(6)}, ${Number(lon).toFixed(6)}`;
|
||||
}
|
||||
|
||||
get osmUrl() {
|
||||
@@ -165,44 +191,35 @@ export default class PlaceDetails extends Component {
|
||||
}
|
||||
|
||||
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}`;
|
||||
}
|
||||
|
||||
get showDescription() {
|
||||
// If it's a Photon result, the description IS the address.
|
||||
// Since we are showing the address in the meta section (bottom),
|
||||
// we should hide the description to avoid duplication.
|
||||
if (this.place.source === 'photon') return false;
|
||||
|
||||
// Otherwise (e.g. saved place with custom description), show it.
|
||||
return !!this.place.description;
|
||||
}
|
||||
|
||||
<template>
|
||||
<div class="place-details">
|
||||
{{#if this.isEditing}}
|
||||
<form class="edit-form" {{on "submit" this.saveChanges}}>
|
||||
<div class="form-group">
|
||||
<label for="edit-title">Title</label>
|
||||
<input
|
||||
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">Save</button>
|
||||
<button type="button" class="btn btn-outline" {{on "click" this.cancelEditing}}>Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
<PlaceEditForm
|
||||
@place={{this.place}}
|
||||
@onSave={{this.saveChanges}}
|
||||
@onCancel={{this.cancelEditing}}
|
||||
/>
|
||||
{{else}}
|
||||
<h3>{{this.name}}</h3>
|
||||
<p class="place-type">
|
||||
{{this.type}}
|
||||
</p>
|
||||
{{#if this.place.description}}
|
||||
{{#if this.showDescription}}
|
||||
<p class="place-description">
|
||||
{{this.place.description}}
|
||||
</p>
|
||||
@@ -242,7 +259,7 @@ export default class PlaceDetails extends Component {
|
||||
<div class="meta-info">
|
||||
|
||||
{{#if this.cuisine}}
|
||||
<p>
|
||||
<p class="cuisine-info">
|
||||
<strong>Cuisine:</strong>
|
||||
{{this.cuisine}}
|
||||
</p>
|
||||
@@ -251,36 +268,42 @@ export default class PlaceDetails extends Component {
|
||||
{{#if this.openingHours}}
|
||||
<p class="content-with-icon">
|
||||
<Icon @name="clock" @title="Opening hours" />
|
||||
<span>{{this.openingHours}}</span>
|
||||
<span>
|
||||
{{this.openingHours}}
|
||||
</span>
|
||||
</p>
|
||||
{{/if}}
|
||||
|
||||
{{#if this.phone}}
|
||||
<p class="content-with-icon">
|
||||
<Icon @name="phone" @title="Phone" />
|
||||
<span><a href="tel:{{this.phone}}">{{this.phone}}</a></span>
|
||||
<span>
|
||||
{{this.phone}}
|
||||
</span>
|
||||
</p>
|
||||
{{/if}}
|
||||
|
||||
{{#if this.website}}
|
||||
<p class="content-with-icon">
|
||||
<Icon @name="globe" @title="Website" />
|
||||
<span><a
|
||||
href={{this.website}}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>{{this.websiteDomain}}</a></span>
|
||||
<span>
|
||||
{{this.website}}
|
||||
</span>
|
||||
</p>
|
||||
{{/if}}
|
||||
|
||||
{{#if this.wikipedia}}
|
||||
<p>
|
||||
<strong>Wikipedia:</strong>
|
||||
<a
|
||||
href="https://wikipedia.org/wiki/{{this.wikipedia}}"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>Article</a>
|
||||
<p class="content-with-icon">
|
||||
<Icon @name="wikipedia" @title="Wikipedia" @filled={{true}} />
|
||||
<span>
|
||||
<a
|
||||
href="https://wikipedia.org/wiki/{{this.wikipedia}}"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Wikipedia
|
||||
</a>
|
||||
</span>
|
||||
</p>
|
||||
{{/if}}
|
||||
|
||||
@@ -305,7 +328,7 @@ export default class PlaceDetails extends Component {
|
||||
|
||||
{{#if this.osmUrl}}
|
||||
<p class="content-with-icon">
|
||||
<Icon @name="map" @title="OSM ID" />
|
||||
<Icon @name="map" />
|
||||
<span>
|
||||
<a href={{this.osmUrl}} target="_blank" rel="noopener noreferrer">
|
||||
OpenStreetMap
|
||||
@@ -314,14 +337,20 @@ export default class PlaceDetails extends Component {
|
||||
</p>
|
||||
{{/if}}
|
||||
|
||||
<p class="content-with-icon">
|
||||
<Icon @name="map" @title="OSM ID" />
|
||||
<span>
|
||||
<a href={{this.gmapsUrl}} target="_blank" rel="noopener noreferrer">
|
||||
Google Maps
|
||||
</a>
|
||||
</span>
|
||||
</p>
|
||||
{{#if this.gmapsUrl}}
|
||||
<p class="content-with-icon">
|
||||
<Icon @name="map" />
|
||||
<span>
|
||||
<a
|
||||
href={{this.gmapsUrl}}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Google Maps
|
||||
</a>
|
||||
</span>
|
||||
</p>
|
||||
{{/if}}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
80
app/components/place-edit-form.gjs
Normal file
80
app/components/place-edit-form.gjs
Normal file
@@ -0,0 +1,80 @@
|
||||
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>
|
||||
{{! template-lint-disable no-autofocus-attribute }}
|
||||
<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>
|
||||
}
|
||||
@@ -4,11 +4,32 @@ import { action } from '@ember/object';
|
||||
import { on } from '@ember/modifier';
|
||||
import { fn } from '@ember/helper';
|
||||
import or from 'ember-truth-helpers/helpers/or';
|
||||
import eq from 'ember-truth-helpers/helpers/eq';
|
||||
import PlaceDetails from './place-details';
|
||||
import Icon from './icon';
|
||||
import humanizeOsmTag from '../helpers/humanize-osm-tag';
|
||||
import { getLocalizedName, getPlaceType } from '../utils/osm';
|
||||
|
||||
export default class PlacesSidebar extends Component {
|
||||
@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
|
||||
selectPlace(place) {
|
||||
@@ -23,13 +44,6 @@ export default class PlacesSidebar extends Component {
|
||||
if (this.args.onSelect) {
|
||||
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
|
||||
@@ -40,19 +54,14 @@ export default class PlacesSidebar extends Component {
|
||||
if (confirm(`Delete "${place.title}"?`)) {
|
||||
try {
|
||||
await this.storage.removePlace(place);
|
||||
console.log('Place deleted:', place.title);
|
||||
console.debug('Place deleted:', place.title);
|
||||
|
||||
// Notify parent to refresh map bookmarks
|
||||
if (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) {
|
||||
// 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
|
||||
const freshPlace = {
|
||||
...place,
|
||||
@@ -65,7 +74,6 @@ export default class PlacesSidebar extends Component {
|
||||
|
||||
// Also fire onSelect if it exists (for list view)
|
||||
if (this.args.onSelect) {
|
||||
// Similar logic for select if needed, but we usually close.
|
||||
this.args.onSelect(null);
|
||||
}
|
||||
|
||||
@@ -81,8 +89,7 @@ export default class PlacesSidebar extends Component {
|
||||
} else {
|
||||
// It's a fresh POI -> Save it
|
||||
const placeData = {
|
||||
title:
|
||||
place.osmTags.name || place.osmTags['name:en'] || 'Untitled Place',
|
||||
title: getLocalizedName(place.osmTags, 'Untitled Place'),
|
||||
lat: place.lat,
|
||||
lon: place.lon,
|
||||
tags: [],
|
||||
@@ -94,7 +101,7 @@ export default class PlacesSidebar extends Component {
|
||||
|
||||
try {
|
||||
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
|
||||
if (this.args.onBookmarkChange) {
|
||||
@@ -121,7 +128,7 @@ export default class PlacesSidebar extends Component {
|
||||
async updateBookmark(updatedPlace) {
|
||||
try {
|
||||
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
|
||||
if (this.args.onBookmarkChange) {
|
||||
@@ -138,6 +145,11 @@ export default class PlacesSidebar extends Component {
|
||||
}
|
||||
}
|
||||
|
||||
get isNearbySearch() {
|
||||
const qp = this.router.currentRoute.queryParams;
|
||||
return !qp.q && qp.lat && qp.lon;
|
||||
}
|
||||
|
||||
<template>
|
||||
<div class="sidebar">
|
||||
<div class="sidebar-header">
|
||||
@@ -148,7 +160,12 @@ export default class PlacesSidebar extends Component {
|
||||
{{on "click" this.clearSelection}}
|
||||
><Icon @name="arrow-left" @size={{20}} @color="#333" /></button>
|
||||
{{else}}
|
||||
<h2><Icon @name="target" @size={{20}} @color="#ea4335" /> Nearby</h2>
|
||||
{{#if this.isNearbySearch}}
|
||||
<h2><Icon @name="target" @size={{20}} @color="#ea4335" />
|
||||
Nearby</h2>
|
||||
{{else}}
|
||||
<h2><Icon @name="search" @size={{20}} @color="#333" /> Results</h2>
|
||||
{{/if}}
|
||||
{{/if}}
|
||||
<button type="button" class="close-btn" {{on "click" @onClose}}><Icon
|
||||
@name="x"
|
||||
@@ -180,20 +197,39 @@ export default class PlacesSidebar extends Component {
|
||||
place.osmTags.name:en
|
||||
"Unnamed Place"
|
||||
}}</div>
|
||||
<div class="place-type">{{or
|
||||
place.osmTags.amenity
|
||||
place.osmTags.shop
|
||||
place.osmTags.tourism
|
||||
place.osmTags.leisure
|
||||
place.osmTags.historic
|
||||
}}</div>
|
||||
<div class="place-type">
|
||||
{{#if (eq place.source "osm")}}
|
||||
{{humanizeOsmTag place.type}}
|
||||
{{else if (eq place.source "photon")}}
|
||||
{{place.description}}
|
||||
{{else}}
|
||||
{{#if place.osmTags}}
|
||||
{{humanizeOsmTag (getPlaceType place.osmTags)}}
|
||||
{{else if place.description}}
|
||||
{{place.description}}
|
||||
{{/if}}
|
||||
{{/if}}
|
||||
</div>
|
||||
</button>
|
||||
</li>
|
||||
{{/each}}
|
||||
</ul>
|
||||
{{else}}
|
||||
<p class="empty-state">No places found nearby.</p>
|
||||
{{#if this.isNearbySearch}}
|
||||
<p class="empty-state">No places found nearby.</p>
|
||||
{{else}}
|
||||
<p class="empty-state">No results found.</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}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
201
app/components/search-box.gjs
Normal file
201
app/components/search-box.gjs
Normal file
@@ -0,0 +1,201 @@
|
||||
import Component from '@glimmer/component';
|
||||
import { service } from '@ember/service';
|
||||
import { tracked } from '@glimmer/tracking';
|
||||
import { action } from '@ember/object';
|
||||
import { on } from '@ember/modifier';
|
||||
import { fn } from '@ember/helper';
|
||||
import { task, timeout } from 'ember-concurrency';
|
||||
import Icon from '#components/icon';
|
||||
import humanizeOsmTag from '../helpers/humanize-osm-tag';
|
||||
import eq from 'ember-truth-helpers/helpers/eq';
|
||||
|
||||
export default class SearchBoxComponent extends Component {
|
||||
@service photon;
|
||||
@service router;
|
||||
@service mapUi;
|
||||
@service map; // Assuming we might need map context, but mostly we use router
|
||||
|
||||
@tracked query = '';
|
||||
@tracked results = [];
|
||||
@tracked isFocused = false;
|
||||
@tracked isLoading = false;
|
||||
|
||||
get showPopover() {
|
||||
return this.isFocused && this.results.length > 0;
|
||||
}
|
||||
|
||||
@action
|
||||
handleInput(event) {
|
||||
this.query = event.target.value;
|
||||
if (this.query.length < 2) {
|
||||
this.results = [];
|
||||
return;
|
||||
}
|
||||
|
||||
this.searchTask.perform();
|
||||
}
|
||||
|
||||
searchTask = task({ restartable: true }, async () => {
|
||||
await timeout(300);
|
||||
|
||||
if (this.query.length < 2) return;
|
||||
|
||||
this.isLoading = true;
|
||||
try {
|
||||
// Use map center if available for location bias
|
||||
let lat, lon;
|
||||
if (this.mapUi.currentCenter) {
|
||||
({ lat, lon } = this.mapUi.currentCenter);
|
||||
}
|
||||
const results = await this.photon.search(this.query, lat, lon);
|
||||
this.results = results;
|
||||
} catch (e) {
|
||||
console.error('Search failed', e);
|
||||
this.results = [];
|
||||
} finally {
|
||||
this.isLoading = false;
|
||||
}
|
||||
});
|
||||
|
||||
@action
|
||||
handleFocus() {
|
||||
this.isFocused = true;
|
||||
this.mapUi.setSearchBoxFocus(true);
|
||||
if (this.query.length >= 2 && this.results.length === 0) {
|
||||
this.searchTask.perform();
|
||||
}
|
||||
}
|
||||
|
||||
@action
|
||||
handleBlur() {
|
||||
// Delay hiding so clicks on results can register
|
||||
setTimeout(() => {
|
||||
this.isFocused = false;
|
||||
this.mapUi.setSearchBoxFocus(false);
|
||||
}, 300);
|
||||
}
|
||||
|
||||
@action
|
||||
handleSubmit(event) {
|
||||
event.preventDefault();
|
||||
if (!this.query) return;
|
||||
|
||||
let queryParams = { q: this.query, selected: null };
|
||||
|
||||
if (this.mapUi.currentCenter) {
|
||||
const { lat, lon } = this.mapUi.currentCenter;
|
||||
queryParams.lat = parseFloat(lat).toFixed(4);
|
||||
queryParams.lon = parseFloat(lon).toFixed(4);
|
||||
}
|
||||
|
||||
this.router.transitionTo('search', { queryParams });
|
||||
this.isFocused = false;
|
||||
}
|
||||
|
||||
@action
|
||||
selectResult(place) {
|
||||
this.query = place.title;
|
||||
this.results = []; // Hide popover
|
||||
|
||||
// If it has an OSM ID, go to place details
|
||||
if (place.osmId) {
|
||||
// Format: osm:node:123
|
||||
// place.osmType is already normalized to 'node', 'way', or 'relation' by PhotonService
|
||||
const id = `osm:${place.osmType}:${place.osmId}`;
|
||||
this.router.transitionTo('place', id);
|
||||
} else {
|
||||
// Just a location (e.g. from Photon without OSM ID, though unlikely for Photon)
|
||||
// Or we can treat it as a search query
|
||||
this.router.transitionTo('search', {
|
||||
queryParams: {
|
||||
q: place.title,
|
||||
lat: place.lat,
|
||||
lon: place.lon,
|
||||
selected: null,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@action
|
||||
clear() {
|
||||
this.query = '';
|
||||
this.results = [];
|
||||
this.router.transitionTo('index'); // Or stay on current page?
|
||||
// Usually clear just clears the input.
|
||||
}
|
||||
|
||||
<template>
|
||||
<div class="search-box">
|
||||
<form class="search-form" {{on "submit" this.handleSubmit}}>
|
||||
<button
|
||||
type="button"
|
||||
class="menu-btn-integrated"
|
||||
aria-label="Menu"
|
||||
{{on "click" @onToggleMenu}}
|
||||
>
|
||||
<Icon @name="menu" @size={{20}} @color="#5f6368" />
|
||||
</button>
|
||||
|
||||
<input
|
||||
type="search"
|
||||
class="search-input"
|
||||
placeholder="Search places..."
|
||||
aria-label="Search places"
|
||||
value={{this.query}}
|
||||
{{on "input" this.handleInput}}
|
||||
{{on "focus" this.handleFocus}}
|
||||
{{on "blur" this.handleBlur}}
|
||||
autocomplete="off"
|
||||
/>
|
||||
|
||||
<button type="submit" class="search-submit-btn" aria-label="Search">
|
||||
<Icon @name="search" @size={{20}} @color="#5f6368" />
|
||||
</button>
|
||||
|
||||
{{#if this.query}}
|
||||
<button
|
||||
type="button"
|
||||
class="search-clear-btn"
|
||||
{{on "click" this.clear}}
|
||||
aria-label="Clear"
|
||||
>
|
||||
<Icon @name="x" @size={{20}} @color="#5f6368" />
|
||||
</button>
|
||||
{{/if}}
|
||||
</form>
|
||||
|
||||
{{#if this.showPopover}}
|
||||
<div class="search-results-popover">
|
||||
<ul class="search-results-list">
|
||||
{{#each this.results as |result|}}
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
class="search-result-item"
|
||||
{{on "click" (fn this.selectResult result)}}
|
||||
>
|
||||
<div class="result-icon">
|
||||
<Icon @name="map-pin" @size={{16}} @color="#666" />
|
||||
</div>
|
||||
<div class="result-info">
|
||||
<span class="result-title">{{result.title}}</span>
|
||||
{{#if (eq result.source "osm")}}
|
||||
<span class="result-desc">{{humanizeOsmTag
|
||||
result.type
|
||||
}}</span>
|
||||
{{else}}
|
||||
{{#if result.description}}
|
||||
<span class="result-desc">{{result.description}}</span>
|
||||
{{/if}}
|
||||
{{/if}}
|
||||
</div>
|
||||
</button>
|
||||
</li>
|
||||
{{/each}}
|
||||
</ul>
|
||||
</div>
|
||||
{{/if}}
|
||||
</div>
|
||||
</template>
|
||||
}
|
||||
@@ -13,10 +13,18 @@ export default class SettingsPane extends Component {
|
||||
this.settings.updateOverpassApi(event.target.value);
|
||||
}
|
||||
|
||||
@action
|
||||
toggleKinetic(event) {
|
||||
this.settings.updateMapKinetic(event.target.value === 'true');
|
||||
}
|
||||
|
||||
<template>
|
||||
<div class="sidebar settings-pane">
|
||||
<div class="sidebar-header">
|
||||
<h2>Marco</h2>
|
||||
<h2>
|
||||
<img src="/icons/icon-rounded.svg" alt="" width="32" height="32" />
|
||||
Marco
|
||||
</h2>
|
||||
<button type="button" class="close-btn" {{on "click" @onClose}}>
|
||||
<Icon @name="x" @size={{20}} @color="#333" />
|
||||
</button>
|
||||
@@ -25,6 +33,27 @@ export default class SettingsPane extends Component {
|
||||
<div class="sidebar-content">
|
||||
<section class="settings-section">
|
||||
<h3>Settings</h3>
|
||||
<div class="form-group">
|
||||
<label for="map-kinetic">Map Inertia (Kinetic Panning)</label>
|
||||
<select
|
||||
id="map-kinetic"
|
||||
class="form-control"
|
||||
{{on "change" this.toggleKinetic}}
|
||||
>
|
||||
<option
|
||||
value="true"
|
||||
selected={{if this.settings.mapKinetic "selected"}}
|
||||
>
|
||||
On
|
||||
</option>
|
||||
<option
|
||||
value="false"
|
||||
selected={{unless this.settings.mapKinetic "selected"}}
|
||||
>
|
||||
Off
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="overpass-api">Overpass API Provider</label>
|
||||
<select
|
||||
@@ -35,7 +64,10 @@ export default class SettingsPane extends Component {
|
||||
{{#each this.settings.overpassApis as |api|}}
|
||||
<option
|
||||
value={{api.url}}
|
||||
selected={{if (eq api.url this.settings.overpassApi) "selected"}}
|
||||
selected={{if
|
||||
(eq api.url this.settings.overpassApi)
|
||||
"selected"
|
||||
}}
|
||||
>
|
||||
{{api.name}}
|
||||
</option>
|
||||
@@ -46,24 +78,45 @@ export default class SettingsPane extends Component {
|
||||
<section class="settings-section">
|
||||
<h3>About</h3>
|
||||
<p>
|
||||
<strong>Marco</strong> (as in <a
|
||||
href="https://en.wikipedia.org/wiki/Marco_Polo"
|
||||
target="_blank" rel="noopener">Marco Polo</a>) is an unhosted maps application
|
||||
that respects your privacy and choices.
|
||||
<strong>Marco</strong>
|
||||
(as in
|
||||
<a
|
||||
href="https://en.wikipedia.org/wiki/Marco_Polo"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>Marco Polo</a>) is an unhosted maps application that respects your
|
||||
privacy and choices.
|
||||
</p>
|
||||
<p>
|
||||
Connect your own <a href="https://remotestorage.io/"
|
||||
target="_blank" rel="noopener">remote storage</a> to sync place bookmarks across
|
||||
apps and devices.
|
||||
Connect your own
|
||||
<a
|
||||
href="https://remotestorage.io/"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>remote storage</a>
|
||||
to sync place bookmarks across apps and devices.
|
||||
</p>
|
||||
<ul class="link-list">
|
||||
<li>
|
||||
<a href="https://gitea.kosmos.org/raucao/marco" target="_blank" rel="noopener">
|
||||
<a
|
||||
href="https://gitea.kosmos.org/raucao/marco"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>
|
||||
Source Code
|
||||
</a> (<a href="https://en.wikipedia.org/wiki/GNU_Affero_General_Public_License" target="_blank" rel="noopener">AGPL</a>)
|
||||
</a>
|
||||
(<a
|
||||
href="https://en.wikipedia.org/wiki/GNU_Affero_General_Public_License"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>AGPL</a>)
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://openstreetmap.org/copyright" target="_blank" rel="noopener">
|
||||
<a
|
||||
href="https://openstreetmap.org/copyright"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>
|
||||
Map Data © OpenStreetMap
|
||||
</a>
|
||||
</li>
|
||||
|
||||
10
app/controllers/search.js
Normal file
10
app/controllers/search.js
Normal file
@@ -0,0 +1,10 @@
|
||||
import Controller from '@ember/controller';
|
||||
|
||||
export default class SearchController extends Controller {
|
||||
queryParams = ['lat', 'lon', 'q', 'selected'];
|
||||
|
||||
lat = null;
|
||||
lon = null;
|
||||
q = null;
|
||||
selected = null;
|
||||
}
|
||||
@@ -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);
|
||||
6
app/helpers/humanize-osm-tag.js
Normal file
6
app/helpers/humanize-osm-tag.js
Normal 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);
|
||||
});
|
||||
4
app/icons/wikipedia.svg
Normal file
4
app/icons/wikipedia.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="128" height="128" viewBox="7.15 7.15 113.7 113.7" fill="currentColor">
|
||||
<path d="M 120.85,29.21 C 120.85,29.62 120.72,29.99 120.47,30.33 C 120.21,30.66 119.94,30.83 119.63,30.83 C 117.14,31.07 115.09,31.87 113.51,33.24 C 111.92,34.6 110.29,37.21 108.6,41.05 L 82.8,99.19 C 82.63,99.73 82.16,100 81.38,100 C 80.77,100 80.3,99.73 79.96,99.19 L 65.49,68.93 L 48.85,99.19 C 48.51,99.73 48.04,100 47.43,100 C 46.69,100 46.2,99.73 45.96,99.19 L 20.61,41.05 C 19.03,37.44 17.36,34.92 15.6,33.49 C 13.85,32.06 11.4,31.17 8.27,30.83 C 8,30.83 7.74,30.69 7.51,30.4 C 7.27,30.12 7.15,29.79 7.15,29.42 C 7.15,28.47 7.42,28 7.96,28 C 10.22,28 12.58,28.1 15.05,28.3 C 17.34,28.51 19.5,28.61 21.52,28.61 C 23.58,28.61 26.01,28.51 28.81,28.3 C 31.74,28.1 34.34,28 36.6,28 C 37.14,28 37.41,28.47 37.41,29.42 C 37.41,30.36 37.24,30.83 36.91,30.83 C 34.65,31 32.87,31.58 31.57,32.55 C 30.27,33.53 29.62,34.81 29.62,36.4 C 29.62,37.21 29.89,38.22 30.43,39.43 L 51.38,86.74 L 63.27,64.28 L 52.19,41.05 C 50.2,36.91 48.56,34.23 47.28,33.03 C 46,31.84 44.06,31.1 41.46,30.83 C 41.22,30.83 41,30.69 40.78,30.4 C 40.56,30.12 40.45,29.79 40.45,29.42 C 40.45,28.47 40.68,28 41.16,28 C 43.42,28 45.49,28.1 47.38,28.3 C 49.2,28.51 51.14,28.61 53.2,28.61 C 55.22,28.61 57.36,28.51 59.62,28.3 C 61.95,28.1 64.24,28 66.5,28 C 67.04,28 67.31,28.47 67.31,29.42 C 67.31,30.36 67.15,30.83 66.81,30.83 C 62.29,31.14 60.03,32.42 60.03,34.68 C 60.03,35.69 60.55,37.26 61.6,39.38 L 68.93,54.26 L 76.22,40.65 C 77.23,38.73 77.74,37.11 77.74,35.79 C 77.74,32.69 75.48,31.04 70.96,30.83 C 70.55,30.83 70.35,30.36 70.35,29.42 C 70.35,29.08 70.45,28.76 70.65,28.46 C 70.86,28.15 71.06,28 71.26,28 C 72.88,28 74.87,28.1 77.23,28.3 C 79.49,28.51 81.35,28.61 82.8,28.61 C 83.84,28.61 85.38,28.52 87.4,28.35 C 89.96,28.12 92.11,28 93.83,28 C 94.23,28 94.43,28.4 94.43,29.21 C 94.43,30.29 94.06,30.83 93.32,30.83 C 90.69,31.1 88.57,31.83 86.97,33.01 C 85.37,34.19 83.37,36.87 80.98,41.05 L 71.26,59.02 L 84.42,85.83 L 103.85,40.65 C 104.52,39 104.86,37.48 104.86,36.1 C 104.86,32.79 102.6,31.04 98.08,30.83 C 97.67,30.83 97.47,30.36 97.47,29.42 C 97.47,28.47 97.77,28 98.38,28 C 100.03,28 101.99,28.1 104.25,28.3 C 106.34,28.51 108.1,28.61 109.51,28.61 C 111,28.61 112.72,28.51 114.67,28.3 C 116.7,28.1 118.52,28 120.14,28 C 120.61,28 120.85,28.4 120.85,29.21 z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.4 KiB |
@@ -8,4 +8,6 @@ export default class Router extends EmberRouter {
|
||||
|
||||
Router.map(function () {
|
||||
this.route('place', { path: '/place/:place_id' });
|
||||
this.route('place.new', { path: '/place/new' });
|
||||
this.route('search');
|
||||
});
|
||||
|
||||
@@ -9,9 +9,13 @@ export default class PlaceRoute extends Route {
|
||||
async model(params) {
|
||||
const id = params.place_id;
|
||||
|
||||
if (id.startsWith('osm:node:') || id.startsWith('osm:way:')) {
|
||||
if (
|
||||
id.startsWith('osm:node:') ||
|
||||
id.startsWith('osm:way:') ||
|
||||
id.startsWith('osm:relation:')
|
||||
) {
|
||||
const [, type, osmId] = id.split(':');
|
||||
console.log(`Fetching explicit OSM ${type}:`, osmId);
|
||||
console.debug(`Fetching explicit OSM ${type}:`, osmId);
|
||||
return this.loadOsmPlace(osmId, type);
|
||||
}
|
||||
|
||||
@@ -20,7 +24,7 @@ export default class PlaceRoute extends Route {
|
||||
let bookmark = this.storage.findPlaceById(id);
|
||||
|
||||
if (bookmark) {
|
||||
console.log('Found in bookmarks:', bookmark.title);
|
||||
console.debug('Found in bookmarks:', bookmark.title);
|
||||
return bookmark;
|
||||
}
|
||||
|
||||
@@ -31,7 +35,7 @@ export default class PlaceRoute extends Route {
|
||||
async waitForSync() {
|
||||
if (this.storage.initialSyncDone) return;
|
||||
|
||||
console.log('Waiting for initial storage sync...');
|
||||
console.debug('Waiting for initial storage sync...');
|
||||
const timeout = 5000;
|
||||
const start = Date.now();
|
||||
|
||||
@@ -44,21 +48,47 @@ export default class PlaceRoute extends Route {
|
||||
}
|
||||
}
|
||||
|
||||
afterModel(model) {
|
||||
async afterModel(model) {
|
||||
// If the model comes from a search result (e.g. Photon), it might lack detailed geometry.
|
||||
// We want to ensure we have the full OSM object (with polygon/linestring) for display.
|
||||
if (
|
||||
model &&
|
||||
model.osmId &&
|
||||
model.osmType &&
|
||||
model.osmType !== 'node' &&
|
||||
!model.geojson
|
||||
) {
|
||||
// Only fetch if it's NOT a node (nodes don't have interesting geometry anyway, just a point)
|
||||
// Although fetching nodes again ensures we have the latest tags too.
|
||||
console.debug('Model missing geometry, fetching full OSM details...');
|
||||
const fullDetails = await this.loadOsmPlace(model.osmId, model.osmType);
|
||||
|
||||
if (fullDetails) {
|
||||
// Update the model in-place with the fuller details
|
||||
Object.assign(model, fullDetails);
|
||||
console.debug('Enriched model with full OSM details', model);
|
||||
}
|
||||
}
|
||||
|
||||
// Notify the Map UI to show the pin
|
||||
if (model) {
|
||||
this.mapUi.selectPlace(model);
|
||||
}
|
||||
// Stop the pulse animation if it was running (e.g. redirected from search)
|
||||
this.mapUi.stopSearch();
|
||||
}
|
||||
|
||||
deactivate() {
|
||||
// Clear the pin when leaving the route
|
||||
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) {
|
||||
try {
|
||||
const poi = await this.osm.getPoiById(id, type);
|
||||
// Use the direct OSM API fetch instead of Overpass for single object lookups
|
||||
const poi = await this.osm.fetchOsmObject(id, type);
|
||||
if (poi) {
|
||||
console.debug('Found OSM POI:', poi);
|
||||
return poi;
|
||||
|
||||
30
app/routes/place/new.js
Normal file
30
app/routes/place/new.js
Normal 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();
|
||||
}
|
||||
}
|
||||
149
app/routes/search.js
Normal file
149
app/routes/search.js
Normal file
@@ -0,0 +1,149 @@
|
||||
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 photon;
|
||||
@service mapUi;
|
||||
@service storage;
|
||||
@service router;
|
||||
|
||||
queryParams = {
|
||||
lat: { refreshModel: true },
|
||||
lon: { refreshModel: true },
|
||||
q: { refreshModel: true },
|
||||
selected: { refreshModel: true },
|
||||
};
|
||||
|
||||
async model(params) {
|
||||
const lat = params.lat ? parseFloat(params.lat) : null;
|
||||
const lon = params.lon ? parseFloat(params.lon) : null;
|
||||
let pois = [];
|
||||
|
||||
// Case 1: Text Search (q parameter present)
|
||||
if (params.q) {
|
||||
// Search with Photon (using lat/lon for bias if available)
|
||||
pois = await this.photon.search(params.q, lat, lon);
|
||||
|
||||
// Search local bookmarks by name
|
||||
const queryLower = params.q.toLowerCase();
|
||||
const localMatches = this.storage.savedPlaces.filter((p) => {
|
||||
return (
|
||||
p.title?.toLowerCase().includes(queryLower) ||
|
||||
p.description?.toLowerCase().includes(queryLower)
|
||||
);
|
||||
});
|
||||
|
||||
// Merge local matches
|
||||
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);
|
||||
}
|
||||
});
|
||||
}
|
||||
// Case 2: Nearby Search (lat/lon present, no q)
|
||||
else if (lat && lon) {
|
||||
const searchRadius = 50; // Default radius
|
||||
|
||||
// Fetch POIs from Overpass
|
||||
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;
|
||||
});
|
||||
|
||||
// Merge local matches
|
||||
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, selected } = transition.to.queryParams;
|
||||
|
||||
// Heuristic Match Logic (ported from MapComponent)
|
||||
// If 'selected' is provided (from map click), try to find that specific feature.
|
||||
// If 'q' is provided (from text search), try to find an exact match to auto-select.
|
||||
const targetName = selected || q;
|
||||
|
||||
if (targetName && model.length > 0) {
|
||||
let matchedPlace = null;
|
||||
|
||||
// 1. Exact Name Match
|
||||
matchedPlace = model.find(
|
||||
(p) =>
|
||||
p.osmTags &&
|
||||
(p.osmTags.name === targetName || p.osmTags['name:en'] === targetName)
|
||||
);
|
||||
|
||||
// 2. High Proximity Match (<= 10m) - Only if we don't have a name match
|
||||
// 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
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,12 @@ import { tracked } from '@glimmer/tracking';
|
||||
|
||||
export default class MapUiService extends Service {
|
||||
@tracked selectedPlace = null;
|
||||
@tracked isSearching = false;
|
||||
@tracked isCreating = false;
|
||||
@tracked creationCoordinates = null;
|
||||
@tracked returnToSearch = false;
|
||||
@tracked currentCenter = null;
|
||||
@tracked searchBoxHasFocus = false;
|
||||
|
||||
selectPlace(place) {
|
||||
this.selectedPlace = place;
|
||||
@@ -11,4 +17,35 @@ export default class MapUiService extends Service {
|
||||
clearSelection() {
|
||||
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 };
|
||||
}
|
||||
|
||||
setSearchBoxFocus(isFocused) {
|
||||
this.searchBoxHasFocus = isFocused;
|
||||
}
|
||||
|
||||
updateCenter(lat, lon) {
|
||||
this.currentCenter = { lat, lon };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,22 @@
|
||||
import Service, { service } from '@ember/service';
|
||||
import { getLocalizedName, getPlaceType } from '../utils/osm';
|
||||
|
||||
export default class OsmService extends Service {
|
||||
@service settings;
|
||||
|
||||
controller = null;
|
||||
cachedResults = null;
|
||||
lastQueryKey = null;
|
||||
|
||||
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
|
||||
if (this.controller) {
|
||||
this.controller.abort();
|
||||
@@ -13,14 +24,31 @@ export default class OsmService extends Service {
|
||||
this.controller = new AbortController();
|
||||
const signal = this.controller.signal;
|
||||
|
||||
const typeKeys = [
|
||||
'amenity',
|
||||
'shop',
|
||||
'tourism',
|
||||
'historic',
|
||||
'leisure',
|
||||
'office',
|
||||
'craft',
|
||||
'building',
|
||||
'landuse',
|
||||
'public_transport',
|
||||
'highway',
|
||||
'aeroway',
|
||||
];
|
||||
const typeKeysQuery = [`~"^(${typeKeys.join('|')})$"~".*"`];
|
||||
|
||||
const query = `
|
||||
[out:json][timeout:25];
|
||||
(
|
||||
nw["amenity"](around:${radius},${lat},${lon});
|
||||
nw["shop"](around:${radius},${lat},${lon});
|
||||
nw["tourism"](around:${radius},${lat},${lon});
|
||||
nw["leisure"](around:${radius},${lat},${lon});
|
||||
nw["historic"](around:${radius},${lat},${lon});
|
||||
node(around:${radius},${lat},${lon})
|
||||
[${typeKeysQuery}][~"^name"~"."];
|
||||
way(around:${radius},${lat},${lon})
|
||||
[${typeKeysQuery}][~"^name"~"."];
|
||||
relation(around:${radius},${lat},${lon})
|
||||
[${typeKeysQuery}][~"^name"~"."];
|
||||
);
|
||||
out center;
|
||||
`.trim();
|
||||
@@ -33,10 +61,16 @@ out center;
|
||||
const data = await res.json();
|
||||
|
||||
// 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) {
|
||||
if (e.name === 'AbortError') {
|
||||
console.log('Overpass request aborted');
|
||||
console.debug('Overpass request aborted');
|
||||
return [];
|
||||
}
|
||||
throw e;
|
||||
@@ -44,15 +78,20 @@ out center;
|
||||
}
|
||||
|
||||
normalizePoi(poi) {
|
||||
const tags = poi.tags || {};
|
||||
const type = getPlaceType(tags) || 'Point of Interest';
|
||||
|
||||
return {
|
||||
title: poi.tags?.name || poi.tags?.['name:en'] || 'Untitled Place',
|
||||
title: getLocalizedName(tags),
|
||||
lat: poi.lat || poi.center?.lat,
|
||||
lon: poi.lon || poi.center?.lon,
|
||||
url: poi.tags?.website,
|
||||
url: tags.website,
|
||||
osmId: String(poi.id),
|
||||
osmType: poi.type,
|
||||
osmTags: poi.tags || {},
|
||||
description: poi.tags?.description,
|
||||
osmTags: tags,
|
||||
description: tags.description,
|
||||
source: 'osm',
|
||||
type: type,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -62,7 +101,7 @@ out center;
|
||||
const res = await fetch(url, options);
|
||||
|
||||
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)`
|
||||
);
|
||||
await new Promise((r) => setTimeout(r, 1000));
|
||||
@@ -72,7 +111,7 @@ out center;
|
||||
return res;
|
||||
} catch (e) {
|
||||
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));
|
||||
return this.fetchWithRetry(url, options, retries - 1);
|
||||
}
|
||||
@@ -107,4 +146,211 @@ out center;
|
||||
if (!data.elements[0]) return null;
|
||||
return this.normalizePoi(data.elements[0]);
|
||||
}
|
||||
|
||||
async fetchOsmObject(osmId, osmType) {
|
||||
if (!osmId || !osmType) return null;
|
||||
|
||||
let url;
|
||||
if (osmType === 'node') {
|
||||
url = `https://www.openstreetmap.org/api/0.6/node/${osmId}.json`;
|
||||
} else if (osmType === 'way') {
|
||||
url = `https://www.openstreetmap.org/api/0.6/way/${osmId}/full.json`;
|
||||
} else if (osmType === 'relation') {
|
||||
url = `https://www.openstreetmap.org/api/0.6/relation/${osmId}/full.json`;
|
||||
} else {
|
||||
console.error('Unknown OSM type:', osmType);
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await this.fetchWithRetry(url);
|
||||
if (!res.ok) {
|
||||
if (res.status === 410) {
|
||||
console.warn('OSM object has been deleted');
|
||||
return null;
|
||||
}
|
||||
throw new Error(`OSM API request failed: ${res.status}`);
|
||||
}
|
||||
const data = await res.json();
|
||||
return this.normalizeOsmApiData(data.elements, osmId, osmType);
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch OSM object:', e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
normalizeOsmApiData(elements, targetId, targetType) {
|
||||
if (!elements || elements.length === 0) return null;
|
||||
|
||||
let mainElement = elements.find(
|
||||
(el) => String(el.id) === String(targetId) && el.type === targetType
|
||||
);
|
||||
|
||||
if (!mainElement) return null;
|
||||
|
||||
// Use a separate variable for the element we want to display (tags, id, specific coords)
|
||||
// vs the element we use for geometry calculation (bbox).
|
||||
let displayElement = mainElement;
|
||||
|
||||
// If it's a boundary relation, try to find the label or admin_centre node
|
||||
// and use that as the display element (better coordinates and tags).
|
||||
if (targetType === 'relation' && mainElement.members) {
|
||||
const labelMember = mainElement.members.find(
|
||||
(m) => m.role === 'label' && m.type === 'node'
|
||||
);
|
||||
const adminCentreMember = mainElement.members.find(
|
||||
(m) => m.role === 'admin_centre' && m.type === 'node'
|
||||
);
|
||||
|
||||
const targetMember = labelMember || adminCentreMember;
|
||||
|
||||
if (targetMember) {
|
||||
const targetNode = elements.find(
|
||||
(el) =>
|
||||
String(el.id) === String(targetMember.ref) && el.type === 'node'
|
||||
);
|
||||
if (targetNode) {
|
||||
displayElement = targetNode;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let lat = displayElement.lat;
|
||||
let lon = displayElement.lon;
|
||||
let bbox = null;
|
||||
let geojson = null;
|
||||
|
||||
// If it's a way, calculate center from nodes
|
||||
if (targetType === 'way' && mainElement.nodes) {
|
||||
const nodeMap = new Map();
|
||||
elements.forEach((el) => {
|
||||
if (el.type === 'node') {
|
||||
nodeMap.set(el.id, [el.lon, el.lat]);
|
||||
}
|
||||
});
|
||||
|
||||
const coords = mainElement.nodes
|
||||
.map((id) => nodeMap.get(id))
|
||||
.filter(Boolean);
|
||||
|
||||
if (coords.length > 0) {
|
||||
// Only override lat/lon if we haven't switched to a specific display node
|
||||
if (displayElement === mainElement) {
|
||||
const sumLat = coords.reduce((sum, c) => sum + c[1], 0);
|
||||
const sumLon = coords.reduce((sum, c) => sum + c[0], 0);
|
||||
lat = sumLat / coords.length;
|
||||
lon = sumLon / coords.length;
|
||||
}
|
||||
|
||||
// Calculate BBox
|
||||
const lats = coords.map((c) => c[1]);
|
||||
const lons = coords.map((c) => c[0]);
|
||||
bbox = {
|
||||
minLat: Math.min(...lats),
|
||||
maxLat: Math.max(...lats),
|
||||
minLon: Math.min(...lons),
|
||||
maxLon: Math.max(...lons),
|
||||
};
|
||||
|
||||
// Construct GeoJSON
|
||||
if (coords.length > 1) {
|
||||
const first = coords[0];
|
||||
const last = coords[coords.length - 1];
|
||||
const isClosed = first[0] === last[0] && first[1] === last[1];
|
||||
|
||||
if (isClosed) {
|
||||
geojson = {
|
||||
type: 'Polygon',
|
||||
coordinates: [coords],
|
||||
};
|
||||
} else {
|
||||
geojson = {
|
||||
type: 'LineString',
|
||||
coordinates: coords,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (targetType === 'relation' && mainElement.members) {
|
||||
// Find all nodes that are part of this relation (directly or via ways)
|
||||
const allNodes = [];
|
||||
const nodeMap = new Map();
|
||||
elements.forEach((el) => {
|
||||
if (el.type === 'node') {
|
||||
nodeMap.set(el.id, el);
|
||||
}
|
||||
});
|
||||
|
||||
const segments = [];
|
||||
|
||||
mainElement.members.forEach((member) => {
|
||||
if (member.type === 'node') {
|
||||
const node = nodeMap.get(member.ref);
|
||||
if (node) allNodes.push(node);
|
||||
} else if (member.type === 'way') {
|
||||
const way = elements.find(
|
||||
(el) => el.type === 'way' && el.id === member.ref
|
||||
);
|
||||
if (way && way.nodes) {
|
||||
const wayCoords = [];
|
||||
way.nodes.forEach((nodeId) => {
|
||||
const node = nodeMap.get(nodeId);
|
||||
if (node) {
|
||||
allNodes.push(node);
|
||||
wayCoords.push([node.lon, node.lat]);
|
||||
}
|
||||
});
|
||||
if (wayCoords.length > 1) {
|
||||
segments.push(wayCoords);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (allNodes.length > 0) {
|
||||
// Only override lat/lon if we haven't switched to a specific display node
|
||||
if (displayElement === mainElement) {
|
||||
const sumLat = allNodes.reduce((sum, n) => sum + n.lat, 0);
|
||||
const sumLon = allNodes.reduce((sum, n) => sum + n.lon, 0);
|
||||
lat = sumLat / allNodes.length;
|
||||
lon = sumLon / allNodes.length;
|
||||
}
|
||||
|
||||
// Calculate BBox
|
||||
const lats = allNodes.map((n) => n.lat);
|
||||
const lons = allNodes.map((n) => n.lon);
|
||||
bbox = {
|
||||
minLat: Math.min(...lats),
|
||||
maxLat: Math.max(...lats),
|
||||
minLon: Math.min(...lons),
|
||||
maxLon: Math.max(...lons),
|
||||
};
|
||||
}
|
||||
|
||||
if (segments.length > 0) {
|
||||
geojson = {
|
||||
type: 'MultiLineString',
|
||||
coordinates: segments,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const tags = displayElement.tags || {};
|
||||
const type = getPlaceType(tags) || 'Point of Interest';
|
||||
|
||||
return {
|
||||
title: getLocalizedName(tags),
|
||||
lat,
|
||||
lon,
|
||||
bbox,
|
||||
geojson,
|
||||
url: tags.website,
|
||||
osmId: String(displayElement.id),
|
||||
osmType: displayElement.type,
|
||||
osmTags: tags,
|
||||
description: tags.description,
|
||||
source: 'osm',
|
||||
type: type,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
119
app/services/photon.js
Normal file
119
app/services/photon.js
Normal file
@@ -0,0 +1,119 @@
|
||||
import Service from '@ember/service';
|
||||
import { getPlaceType } from '../utils/osm';
|
||||
import { humanizeOsmTag } from '../utils/format-text';
|
||||
|
||||
export default class PhotonService extends Service {
|
||||
baseUrl = 'https://photon.komoot.io/api/';
|
||||
|
||||
async search(query, lat, lon, limit = 10) {
|
||||
if (!query || query.length < 2) return [];
|
||||
|
||||
const params = new URLSearchParams({
|
||||
q: query,
|
||||
limit: String(limit),
|
||||
});
|
||||
|
||||
if (lat && lon) {
|
||||
params.append('lat', parseFloat(lat).toFixed(4));
|
||||
params.append('lon', parseFloat(lon).toFixed(4));
|
||||
}
|
||||
|
||||
const url = `${this.baseUrl}?${params.toString()}`;
|
||||
|
||||
try {
|
||||
const res = await this.fetchWithRetry(url);
|
||||
if (!res.ok) {
|
||||
throw new Error(`Photon request failed with status ${res.status}`);
|
||||
}
|
||||
const data = await res.json();
|
||||
|
||||
if (!data.features) return [];
|
||||
|
||||
return data.features.map((f) => this.normalizeFeature(f));
|
||||
} catch (e) {
|
||||
console.error('Photon search error:', e);
|
||||
// Return empty array on error so UI doesn't break
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
normalizeFeature(feature) {
|
||||
const props = feature.properties || {};
|
||||
const geom = feature.geometry || {};
|
||||
const coords = geom.coordinates || [];
|
||||
|
||||
// Photon returns [lon, lat] for Point geometries
|
||||
const lon = coords[0];
|
||||
const lat = coords[1];
|
||||
|
||||
// Construct a description from address fields
|
||||
// Priority: name -> street -> city -> state -> country
|
||||
const addressParts = [];
|
||||
if (props.street)
|
||||
addressParts.push(
|
||||
props.housenumber
|
||||
? `${props.street} ${props.housenumber}`
|
||||
: props.street
|
||||
);
|
||||
if (props.city && props.city !== props.name) addressParts.push(props.city);
|
||||
if (props.state && props.state !== props.city)
|
||||
addressParts.push(props.state);
|
||||
if (props.country) addressParts.push(props.country);
|
||||
|
||||
const description = addressParts.join(', ');
|
||||
const title = props.name || description || 'Unknown Place';
|
||||
|
||||
const osmTypeMap = {
|
||||
N: 'node',
|
||||
W: 'way',
|
||||
R: 'relation',
|
||||
};
|
||||
|
||||
const osmTags = { ...props };
|
||||
// Photon often returns osm_key and osm_value for the main tag
|
||||
if (props.osm_key && props.osm_value) {
|
||||
osmTags[props.osm_key] = props.osm_value;
|
||||
}
|
||||
|
||||
const type = getPlaceType(osmTags) || humanizeOsmTag(props.osm_value);
|
||||
|
||||
return {
|
||||
title,
|
||||
lat,
|
||||
lon,
|
||||
osmId: props.osm_id,
|
||||
osmType: osmTypeMap[props.osm_type] || props.osm_type, // 'node', 'way', 'relation'
|
||||
osmTags,
|
||||
description: props.name ? description : addressParts.slice(1).join(', '),
|
||||
source: 'photon',
|
||||
type: type,
|
||||
};
|
||||
}
|
||||
|
||||
async fetchWithRetry(url, options = {}, retries = 3) {
|
||||
try {
|
||||
// eslint-disable-next-line warp-drive/no-external-request-patterns
|
||||
const res = await fetch(url, options);
|
||||
|
||||
// Retry on 5xx errors or 429 Too Many Requests
|
||||
if (!res.ok && retries > 0 && [502, 503, 504, 429].includes(res.status)) {
|
||||
console.warn(
|
||||
`Photon request failed with ${res.status}. Retrying... (${retries} left)`
|
||||
);
|
||||
// Exponential backoff or fixed delay? Let's do 1s fixed delay for simplicity
|
||||
await new Promise((r) => setTimeout(r, 1000));
|
||||
return this.fetchWithRetry(url, options, retries - 1);
|
||||
}
|
||||
|
||||
return res;
|
||||
} catch (e) {
|
||||
// Retry on network errors (fetch throws) except AbortError
|
||||
if (retries > 0 && e.name !== 'AbortError') {
|
||||
console.debug(`Retrying Photon request... (${retries} left)`, e);
|
||||
await new Promise((r) => setTimeout(r, 1000));
|
||||
return this.fetchWithRetry(url, options, retries - 1);
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,15 +2,26 @@ import Service from '@ember/service';
|
||||
import { tracked } from '@glimmer/tracking';
|
||||
|
||||
export default class SettingsService extends Service {
|
||||
@tracked overpassApi = 'https://overpass.bke.ro/api/interpreter';
|
||||
@tracked overpassApi = 'https://overpass-api.de/api/interpreter';
|
||||
@tracked mapKinetic = true;
|
||||
|
||||
overpassApis = [
|
||||
{ name: 'bke.ro', url: 'https://overpass.bke.ro/api/interpreter' },
|
||||
{ name: 'overpass-api.de', url: 'https://overpass-api.de/api/interpreter' },
|
||||
{
|
||||
name: 'private.coffee',
|
||||
name: 'overpass-api.de (DE)',
|
||||
url: 'https://overpass-api.de/api/interpreter',
|
||||
},
|
||||
{
|
||||
name: 'private.coffee (AT)',
|
||||
url: 'https://overpass.private.coffee/api/interpreter',
|
||||
},
|
||||
// {
|
||||
// name: 'overpass.openstreetmap.us (US)',
|
||||
// url: 'https://overpass.openstreetmap.us/api/interpreter'
|
||||
// },
|
||||
// {
|
||||
// name: 'bke.ro (US)',
|
||||
// url: 'https://overpass.bke.ro/api/interpreter'
|
||||
// },
|
||||
];
|
||||
|
||||
constructor() {
|
||||
@@ -19,14 +30,33 @@ export default class SettingsService extends Service {
|
||||
}
|
||||
|
||||
loadSettings() {
|
||||
const savedApi = localStorage.getItem('marco-overpass-api');
|
||||
const savedApi = localStorage.getItem('marco:overpass-api');
|
||||
if (savedApi) {
|
||||
this.overpassApi = savedApi;
|
||||
// Check if saved API is still in the allowed list
|
||||
const isValid = this.overpassApis.some((api) => api.url === savedApi);
|
||||
if (isValid) {
|
||||
this.overpassApi = savedApi;
|
||||
} else {
|
||||
// If not valid, revert to default
|
||||
this.overpassApi = 'https://overpass-api.de/api/interpreter';
|
||||
localStorage.setItem('marco:overpass-api', this.overpassApi);
|
||||
}
|
||||
}
|
||||
|
||||
const savedKinetic = localStorage.getItem('marco:map-kinetic');
|
||||
if (savedKinetic !== null) {
|
||||
this.mapKinetic = savedKinetic === 'true';
|
||||
}
|
||||
// Default is true (initialized in class field)
|
||||
}
|
||||
|
||||
updateOverpassApi(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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,7 +23,6 @@ export default class StorageService extends Service {
|
||||
|
||||
constructor() {
|
||||
super(...arguments);
|
||||
console.log('ohai');
|
||||
|
||||
this.rs = new RemoteStorage({
|
||||
modules: [Places],
|
||||
@@ -45,13 +44,11 @@ export default class StorageService extends Service {
|
||||
});
|
||||
|
||||
this.rs.on('connected', () => {
|
||||
console.debug('Remote storage connected');
|
||||
this.connected = true;
|
||||
this.userAddress = this.rs.remote.userAddress;
|
||||
});
|
||||
|
||||
this.rs.on('disconnected', () => {
|
||||
console.debug('Remote storage disconnected');
|
||||
this.connected = false;
|
||||
this.userAddress = null;
|
||||
this.placesInView = [];
|
||||
@@ -77,18 +74,7 @@ export default class StorageService extends Service {
|
||||
handlePlaceChange(event) {
|
||||
const { newValue, relativePath } = event;
|
||||
|
||||
// Remove old entry if exists
|
||||
// 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.
|
||||
// Extract ID from path (structure: <2-char>/<2-char>/<id>)
|
||||
const pathParts = relativePath.split('/');
|
||||
const id = pathParts[pathParts.length - 1];
|
||||
|
||||
@@ -128,7 +114,7 @@ export default class StorageService extends Service {
|
||||
|
||||
// Recalculate prefixes for the current view
|
||||
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)
|
||||
this.loadAllPlaces(required);
|
||||
@@ -144,20 +130,15 @@ export default class StorageService extends Service {
|
||||
);
|
||||
|
||||
if (missingPrefixes.length === 0) {
|
||||
// console.log('All prefixes already loaded for this view');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('Loading new prefixes:', missingPrefixes);
|
||||
console.debug('Loading new prefixes:', missingPrefixes);
|
||||
|
||||
// 3. Load places for only the new prefixes
|
||||
await this.loadAllPlaces(missingPrefixes);
|
||||
|
||||
// 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.currentBbox = bbox;
|
||||
}
|
||||
@@ -195,7 +176,7 @@ export default class StorageService extends Service {
|
||||
} else {
|
||||
if (!prefixes) this.placesInView = [];
|
||||
}
|
||||
console.log('Loaded saved places:', this.placesInView.length);
|
||||
console.debug('Loaded saved places:', this.placesInView.length);
|
||||
} catch (e) {
|
||||
console.error('Failed to load places:', e);
|
||||
}
|
||||
@@ -216,29 +197,56 @@ export default class StorageService extends Service {
|
||||
|
||||
async storePlace(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)) {
|
||||
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;
|
||||
}
|
||||
|
||||
async updatePlace(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);
|
||||
if (index !== -1) {
|
||||
const newPlaces = [...this.savedPlaces];
|
||||
newPlaces[index] = savedPlace;
|
||||
this.savedPlaces = newPlaces;
|
||||
}
|
||||
|
||||
// Update Map View
|
||||
this.placesInView = this.placesInView.map((p) =>
|
||||
p.id === savedPlace.id ? savedPlace : p
|
||||
);
|
||||
|
||||
return savedPlace;
|
||||
}
|
||||
|
||||
async removePlace(place) {
|
||||
await this.places.remove(place.id, place.geohash);
|
||||
|
||||
// Update both lists
|
||||
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
|
||||
|
||||
@@ -5,12 +5,18 @@ body {
|
||||
height: 100%;
|
||||
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 {
|
||||
margin: 0;
|
||||
font-family: 'Noto Serif', sans-serif;
|
||||
font-family: 'Noto Sans', sans-serif;
|
||||
font-size: 16px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
#root,
|
||||
@@ -55,7 +61,7 @@ body {
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 60px;
|
||||
padding: 0 1rem;
|
||||
padding: 0.5rem 1rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
@@ -63,26 +69,27 @@ body {
|
||||
pointer-events: none; /* Let clicks pass through to map where transparent */
|
||||
}
|
||||
|
||||
@media (width <= 768px) {
|
||||
.app-header {
|
||||
padding: 0 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.header-left,
|
||||
.header-right {
|
||||
pointer-events: auto; /* Re-enable clicks for buttons */
|
||||
}
|
||||
|
||||
.icon-btn {
|
||||
background: white;
|
||||
border: none;
|
||||
border-radius: 50%;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 2px 5px rgb(0 0 0 / 20%);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn-press {
|
||||
transition: transform 0.1s;
|
||||
}
|
||||
|
||||
.icon-btn:active {
|
||||
.btn-press:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
@@ -96,7 +103,7 @@ body {
|
||||
.user-avatar-placeholder {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
background: #333;
|
||||
background: #2a3743;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -192,7 +199,6 @@ body {
|
||||
bottom: 0;
|
||||
width: 300px;
|
||||
background: white;
|
||||
color: #333;
|
||||
z-index: 3100; /* Higher than Header (3000) */
|
||||
box-shadow: 2px 0 5px rgb(0 0 0 / 10%);
|
||||
display: flex;
|
||||
@@ -203,7 +209,6 @@ body {
|
||||
z-index: 3200; /* Higher than Places Sidebar (3100) */
|
||||
}
|
||||
|
||||
/* Settings Pane Mobile Overrides */
|
||||
@media (width <= 768px) {
|
||||
.settings-pane.sidebar {
|
||||
width: 100%;
|
||||
@@ -340,7 +345,6 @@ body {
|
||||
.meta-info a {
|
||||
color: #007bff;
|
||||
text-decoration: none;
|
||||
padding-bottom: 4rem;
|
||||
}
|
||||
|
||||
.meta-info a:hover {
|
||||
@@ -370,10 +374,7 @@ body {
|
||||
.places-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: -1rem -1rem 0 -1rem;
|
||||
}
|
||||
|
||||
.places-list li {
|
||||
margin: -1rem -1rem 0;
|
||||
}
|
||||
|
||||
.place-item {
|
||||
@@ -402,7 +403,6 @@ body {
|
||||
.place-type {
|
||||
color: #666;
|
||||
font-size: 0.85rem;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.back-btn {
|
||||
@@ -436,7 +436,6 @@ body {
|
||||
.place-details .place-type {
|
||||
color: #666;
|
||||
font-size: 0.9rem;
|
||||
text-transform: capitalize;
|
||||
margin: 0 0 1rem;
|
||||
}
|
||||
|
||||
@@ -509,6 +508,7 @@ body {
|
||||
border: 2px solid rgb(255 204 51 / 80%); /* Gold/Yellow to match markers */
|
||||
background: rgb(255 204 51 / 20%);
|
||||
position: absolute;
|
||||
|
||||
/* Use translate3d for GPU acceleration on iOS */
|
||||
transform: translate3d(-50%, -50%, 0);
|
||||
pointer-events: none;
|
||||
@@ -541,24 +541,58 @@ body {
|
||||
}
|
||||
}
|
||||
|
||||
/* Locate Control */
|
||||
/* Map controls */
|
||||
|
||||
.ol-control.ol-attribution {
|
||||
bottom: 1rem;
|
||||
}
|
||||
|
||||
.ol-touch .ol-control.ol-attribution {
|
||||
bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.ol-control.ol-zoom {
|
||||
bottom: 3rem;
|
||||
}
|
||||
|
||||
.ol-touch .ol-control.ol-zoom {
|
||||
bottom: 3.5rem;
|
||||
}
|
||||
|
||||
.ol-control.ol-locate {
|
||||
inset: auto 0.5em 2.5em auto;
|
||||
bottom: 6.5rem;
|
||||
}
|
||||
|
||||
.ol-touch .ol-control.ol-locate {
|
||||
inset: auto 0.5em 3.5em auto;
|
||||
bottom: 8.5rem;
|
||||
}
|
||||
|
||||
/* Rotate Control */
|
||||
.ol-rotate {
|
||||
inset: auto 0.5em 5em auto;
|
||||
.ol-control.ol-rotate {
|
||||
bottom: 9rem;
|
||||
}
|
||||
|
||||
.ol-touch .ol-rotate {
|
||||
inset: auto 0.5em 6em auto;
|
||||
.ol-touch .ol-control.ol-rotate {
|
||||
bottom: 11.5rem;
|
||||
}
|
||||
|
||||
.ol-control.ol-attribution,
|
||||
.ol-control.ol-zoom,
|
||||
.ol-control.ol-locate,
|
||||
.ol-control.ol-rotate {
|
||||
top: auto;
|
||||
left: auto;
|
||||
right: 1rem;
|
||||
}
|
||||
|
||||
.ol-touch .ol-control.ol-attribution,
|
||||
.ol-touch .ol-control.ol-zoom,
|
||||
.ol-touch .ol-control.ol-locate,
|
||||
.ol-touch .ol-control.ol-rotate {
|
||||
right: 0.5rem;
|
||||
}
|
||||
|
||||
/* Icons */
|
||||
|
||||
span.icon {
|
||||
display: inline-block;
|
||||
}
|
||||
@@ -577,13 +611,22 @@ span.icon {
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
|
||||
.icon-filled svg {
|
||||
stroke: none;
|
||||
fill: currentcolor;
|
||||
}
|
||||
|
||||
.content-with-icon {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
align-items: flex-start;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.content-with-icon .icon {
|
||||
margin-top: 0.15rem;
|
||||
}
|
||||
|
||||
/* Selected Pin Animation */
|
||||
.selected-pin-container {
|
||||
position: absolute;
|
||||
@@ -651,6 +694,68 @@ 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) {
|
||||
.sidebar {
|
||||
width: 100%;
|
||||
@@ -669,3 +774,202 @@ span.icon {
|
||||
padding-bottom: env(safe-area-inset-bottom, 20px);
|
||||
}
|
||||
}
|
||||
|
||||
/* Search Box Component */
|
||||
.search-box {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
margin-left: 0;
|
||||
z-index: 3002; /* Higher than menu button to be safe */
|
||||
}
|
||||
|
||||
@media (width <= 768px) {
|
||||
.search-box {
|
||||
max-width: calc(100vw - 65px);
|
||||
}
|
||||
}
|
||||
|
||||
.search-form {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: white;
|
||||
border-radius: 24px; /* Pill shape */
|
||||
box-shadow: 0 2px 5px rgb(0 0 0 / 15%);
|
||||
padding: 0 0.5rem;
|
||||
height: 48px; /* Slightly taller for touch targets */
|
||||
transition: box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.search-form:focus-within {
|
||||
box-shadow: 0 4px 12px rgb(0 0 0 / 15%);
|
||||
}
|
||||
|
||||
/* Integrated Menu Button */
|
||||
.menu-btn-integrated {
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: 8px;
|
||||
margin-right: 4px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
color: #5f6368;
|
||||
}
|
||||
|
||||
.menu-btn-integrated:hover {
|
||||
background: rgb(0 0 0 / 5%);
|
||||
}
|
||||
|
||||
/* Fallback Search Icon (Left) */
|
||||
.search-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #5f6368;
|
||||
margin-right: 0.5rem;
|
||||
padding: 8px; /* Match button size */
|
||||
}
|
||||
|
||||
.search-input {
|
||||
border: none;
|
||||
background: transparent;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
height: 100%;
|
||||
font-size: 1rem;
|
||||
color: #333;
|
||||
outline: none;
|
||||
width: 100%;
|
||||
padding: 0 4px;
|
||||
|
||||
/* Remove native search cancel button in WebKit */
|
||||
-webkit-appearance: none;
|
||||
}
|
||||
|
||||
/* Remove 'x' from search input in Chrome/Safari */
|
||||
.search-input::-webkit-search-cancel-button {
|
||||
-webkit-appearance: none;
|
||||
}
|
||||
|
||||
/* Submit Button (Right) */
|
||||
.search-submit-btn {
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: 8px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #5f6368;
|
||||
border-radius: 50%;
|
||||
margin-left: 4px;
|
||||
padding-left: 8px;
|
||||
}
|
||||
|
||||
.search-submit-btn:hover {
|
||||
background: rgb(0 0 0 / 5%);
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.search-clear-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 8px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #5f6368;
|
||||
border-radius: 50%;
|
||||
margin-left: 2px;
|
||||
}
|
||||
|
||||
.search-clear-btn:hover {
|
||||
background: rgb(0 0 0 / 5%);
|
||||
color: #333;
|
||||
}
|
||||
|
||||
/* Search Results Popover */
|
||||
.search-results-popover {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
margin-top: 8px;
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 12px rgb(0 0 0 / 15%);
|
||||
overflow: hidden;
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
z-index: 3002;
|
||||
}
|
||||
|
||||
.search-results-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.search-result-item {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center; /* Vertical center alignment */
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem 1rem;
|
||||
border: none;
|
||||
background: white;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: background 0.1s;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.search-result-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.search-result-item:hover,
|
||||
.search-result-item:focus {
|
||||
background: #f5f5f5;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.result-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
background: #f0f0f0;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.result-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden; /* For text truncation if needed */
|
||||
}
|
||||
|
||||
.result-title {
|
||||
font-weight: 500;
|
||||
color: #333;
|
||||
font-size: 0.95rem;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.result-desc {
|
||||
font-size: 0.8rem;
|
||||
color: #777;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
@@ -1,61 +1,38 @@
|
||||
import Component from '@glimmer/component';
|
||||
import { pageTitle } from 'ember-page-title';
|
||||
import Map from '#components/map';
|
||||
import PlacesSidebar from '#components/places-sidebar';
|
||||
import AppHeader from '#components/app-header';
|
||||
import SettingsPane from '#components/settings-pane';
|
||||
import { service } from '@ember/service';
|
||||
import { tracked } from '@glimmer/tracking';
|
||||
import { action } from '@ember/object';
|
||||
import { eq } from 'ember-truth-helpers';
|
||||
import { and, or } from 'ember-truth-helpers';
|
||||
import { or } from 'ember-truth-helpers';
|
||||
import { on } from '@ember/modifier';
|
||||
|
||||
export default class ApplicationComponent extends Component {
|
||||
@service storage;
|
||||
@service mapUi;
|
||||
@service router;
|
||||
|
||||
@tracked nearbyPlaces = null;
|
||||
@tracked isSettingsOpen = false;
|
||||
// @tracked bookmarksVersion = 0; // Moved to storage service
|
||||
|
||||
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() {
|
||||
super(...arguments);
|
||||
console.log('Application component constructed');
|
||||
console.debug('Application component constructed');
|
||||
// Access the service to ensure it is instantiated
|
||||
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
|
||||
toggleSettings() {
|
||||
this.isSettingsOpen = !this.isSettingsOpen;
|
||||
@@ -66,29 +43,20 @@ export default class ApplicationComponent extends Component {
|
||||
this.isSettingsOpen = false;
|
||||
}
|
||||
|
||||
@action
|
||||
selectFromList(place) {
|
||||
if (place) {
|
||||
// Optimize: Pass full object to avoid fetch
|
||||
this.router.transitionTo('place', place);
|
||||
}
|
||||
}
|
||||
|
||||
@action
|
||||
handleOutsideClick() {
|
||||
if (this.isSettingsOpen) {
|
||||
this.closeSettings();
|
||||
} else {
|
||||
this.closeSidebar();
|
||||
} else if (this.router.currentRouteName === 'search') {
|
||||
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
|
||||
refreshBookmarks() {
|
||||
this.storage.notifyChange();
|
||||
@@ -113,19 +81,10 @@ export default class ApplicationComponent extends Component {
|
||||
{{/if}}
|
||||
|
||||
<Map
|
||||
@onPlacesFound={{this.showPlaces}}
|
||||
@isSidebarOpen={{or this.isSidebarOpen this.isSettingsOpen}}
|
||||
@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}}
|
||||
<SettingsPane @onClose={{this.closeSettings}} />
|
||||
{{/if}}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { tracked } from '@glimmer/tracking';
|
||||
export default class PlaceTemplate extends Component {
|
||||
@service router;
|
||||
@service storage;
|
||||
@service mapUi;
|
||||
|
||||
@tracked localPlace = null;
|
||||
|
||||
@@ -62,7 +63,7 @@ export default class PlaceTemplate extends Component {
|
||||
|
||||
@action
|
||||
handleUpdate(newPlace) {
|
||||
console.log('Updating local place state:', newPlace);
|
||||
console.debug('Updating local place state:', newPlace);
|
||||
this.localPlace = newPlace;
|
||||
this.storage.notifyChange();
|
||||
}
|
||||
@@ -72,8 +73,26 @@ export default class PlaceTemplate extends Component {
|
||||
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
|
||||
close() {
|
||||
// Clear search results so we don't fall back to the list
|
||||
this.router.transitionTo('index');
|
||||
}
|
||||
|
||||
@@ -81,6 +100,7 @@ export default class PlaceTemplate extends Component {
|
||||
<PlacesSidebar
|
||||
@selectedPlace={{this.place}}
|
||||
@onClose={{this.close}}
|
||||
@onSelect={{this.navigateBack}}
|
||||
@onBookmarkChange={{this.refreshMap}}
|
||||
@onUpdate={{this.handleUpdate}}
|
||||
/>
|
||||
|
||||
83
app/templates/place/new.gjs
Normal file
83
app/templates/place/new.gjs
Normal 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
30
app/templates/search.gjs
Normal 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
9
app/utils/format-text.js
Normal 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())
|
||||
);
|
||||
}
|
||||
70
app/utils/osm.js
Normal file
70
app/utils/osm.js
Normal file
@@ -0,0 +1,70 @@
|
||||
import { humanizeOsmTag } from './format-text';
|
||||
|
||||
export function getLocalizedName(tags, defaultName = 'Untitled Place') {
|
||||
if (!tags) return defaultName;
|
||||
|
||||
// 1. Get user's preferred languages
|
||||
const languages = navigator.languages || [navigator.language || 'en'];
|
||||
|
||||
// 2. Try to find a match for each preferred language
|
||||
for (const lang of languages) {
|
||||
if (!lang) continue;
|
||||
|
||||
// Handle "en-US", "de-DE", etc. -> look for "name:en", "name:de"
|
||||
const shortLang = lang.split('-')[0];
|
||||
const tagKey = `name:${shortLang}`;
|
||||
|
||||
if (tags[tagKey]) {
|
||||
return tags[tagKey];
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Fallback to standard "name"
|
||||
if (tags.name) {
|
||||
return tags.name;
|
||||
}
|
||||
|
||||
// 4. Fallback to "name:en" (common in international places without local name)
|
||||
if (tags['name:en']) {
|
||||
return tags['name:en'];
|
||||
}
|
||||
|
||||
// 5. Final fallback
|
||||
return defaultName;
|
||||
}
|
||||
|
||||
const PLACE_TYPE_KEYS = [
|
||||
'amenity',
|
||||
'shop',
|
||||
'tourism',
|
||||
'historic',
|
||||
'leisure',
|
||||
'office',
|
||||
'craft',
|
||||
'building',
|
||||
'landuse',
|
||||
'public_transport',
|
||||
'highway',
|
||||
'aeroway',
|
||||
'waterway',
|
||||
'natural',
|
||||
'place',
|
||||
'border_type',
|
||||
'admin_title',
|
||||
];
|
||||
|
||||
export function getPlaceType(tags) {
|
||||
if (!tags) return null;
|
||||
|
||||
for (const key of PLACE_TYPE_KEYS) {
|
||||
const value = tags[key];
|
||||
if (value) {
|
||||
if (value === 'yes') {
|
||||
return humanizeOsmTag(key);
|
||||
}
|
||||
return humanizeOsmTag(value);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -2,6 +2,9 @@ import { dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { setConfig } from '@warp-drive/core/build-config';
|
||||
import { buildMacros } from '@embroider/macros/babel';
|
||||
import asyncArrowTaskTransform from 'ember-concurrency/async-arrow-task-transform';
|
||||
|
||||
console.log('Babel config loading, plugin:', typeof asyncArrowTaskTransform);
|
||||
|
||||
const macros = buildMacros({
|
||||
configure: (config) => {
|
||||
@@ -14,6 +17,7 @@ const macros = buildMacros({
|
||||
|
||||
export default {
|
||||
plugins: [
|
||||
asyncArrowTaskTransform,
|
||||
[
|
||||
'babel-plugin-ember-template-compilation',
|
||||
{
|
||||
|
||||
17
index.html
17
index.html
@@ -3,13 +3,26 @@
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Marco</title>
|
||||
<meta name="description" content="">
|
||||
<meta name="description" content="Unhosted maps app that respects your privacy and choices.">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
|
||||
<!-- Open Graph -->
|
||||
<meta property="og:title" content="Marco">
|
||||
<meta property="og:description" content="Unhosted maps app that respects your privacy and choices.">
|
||||
<meta property="og:type" content="website">
|
||||
<meta property="og:url" content="https://marco.kosmos.org">
|
||||
<meta property="og:image" content="https://marco.kosmos.org/icons/icon-512.png">
|
||||
|
||||
<!-- Twitter Card -->
|
||||
<meta name="twitter:card" content="summary">
|
||||
<meta name="twitter:title" content="Marco">
|
||||
<meta name="twitter:description" content="Unhosted maps app that respects your privacy and choices.">
|
||||
<meta name="twitter:image" content="https://marco.kosmos.org/icons/icon-512.png">
|
||||
|
||||
<!-- App identity -->
|
||||
<meta name="application-name" 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 -->
|
||||
<link rel="manifest" href="/web-app-manifest.json">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "marco",
|
||||
"version": "1.8.10",
|
||||
"version": "1.12.1",
|
||||
"private": true,
|
||||
"description": "Unhosted maps app",
|
||||
"repository": {
|
||||
@@ -25,15 +25,17 @@
|
||||
"format": "prettier . --cache --write",
|
||||
"lint": "concurrently \"pnpm:lint:*(!fix)\" --names \"lint:\" --prefixColors auto",
|
||||
"lint:css": "stylelint \"**/*.css\"",
|
||||
"lint:css:fix": "concurrently \"pnpm:lint:css -- --fix\"",
|
||||
"lint:css:fix": "stylelint \"**/*.css\" --fix",
|
||||
"lint:fix": "concurrently \"pnpm:lint:*:fix\" --names \"fix:\" --prefixColors auto && pnpm format",
|
||||
"lint:format": "prettier . --cache --check",
|
||||
"lint:format:fix": "prettier . --cache --write",
|
||||
"lint:hbs": "ember-template-lint .",
|
||||
"lint:hbs:fix": "ember-template-lint . --fix",
|
||||
"lint:js": "eslint . --cache",
|
||||
"lint:js:fix": "eslint . --fix",
|
||||
"start": "vite",
|
||||
"test": "vite build --mode development && testem ci --port 0",
|
||||
"preversion": "pnpm test",
|
||||
"version": "pnpm build && git add release/"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -100,6 +102,7 @@
|
||||
"edition": "octane"
|
||||
},
|
||||
"dependencies": {
|
||||
"ember-concurrency": "^5.2.0",
|
||||
"ember-lifeline": "^7.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
46
pnpm-lock.yaml
generated
46
pnpm-lock.yaml
generated
@@ -8,6 +8,9 @@ importers:
|
||||
|
||||
.:
|
||||
dependencies:
|
||||
ember-concurrency:
|
||||
specifier: ^5.2.0
|
||||
version: 5.2.0(@babel/core@7.28.6)
|
||||
ember-lifeline:
|
||||
specifier: ^7.0.0
|
||||
version: 7.0.0(@ember/test-helpers@5.4.1(@babel/core@7.28.6))
|
||||
@@ -1436,66 +1439,79 @@ packages:
|
||||
resolution: {integrity: sha512-Rn3n+FUk2J5VWx+ywrG/HGPTD9jXNbicRtTM11e/uorplArnXZYsVifnPPqNNP5BsO3roI4n8332ukpY/zN7rQ==}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-arm-musleabihf@4.55.1':
|
||||
resolution: {integrity: sha512-grPNWydeKtc1aEdrJDWk4opD7nFtQbMmV7769hiAaYyUKCT1faPRm2av8CX1YJsZ4TLAZcg9gTR1KvEzoLjXkg==}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-linux-arm64-gnu@4.55.1':
|
||||
resolution: {integrity: sha512-a59mwd1k6x8tXKcUxSyISiquLwB5pX+fJW9TkWU46lCqD/GRDe9uDN31jrMmVP3feI3mhAdvcCClhV8V5MhJFQ==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-arm64-musl@4.55.1':
|
||||
resolution: {integrity: sha512-puS1MEgWX5GsHSoiAsF0TYrpomdvkaXm0CofIMG5uVkP6IBV+ZO9xhC5YEN49nsgYo1DuuMquF9+7EDBVYu4uA==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-linux-loong64-gnu@4.55.1':
|
||||
resolution: {integrity: sha512-r3Wv40in+lTsULSb6nnoudVbARdOwb2u5fpeoOAZjFLznp6tDU8kd+GTHmJoqZ9lt6/Sys33KdIHUaQihFcu7g==}
|
||||
cpu: [loong64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-loong64-musl@4.55.1':
|
||||
resolution: {integrity: sha512-MR8c0+UxAlB22Fq4R+aQSPBayvYa3+9DrwG/i1TKQXFYEaoW3B5b/rkSRIypcZDdWjWnpcvxbNaAJDcSbJU3Lw==}
|
||||
cpu: [loong64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-linux-ppc64-gnu@4.55.1':
|
||||
resolution: {integrity: sha512-3KhoECe1BRlSYpMTeVrD4sh2Pw2xgt4jzNSZIIPLFEsnQn9gAnZagW9+VqDqAHgm1Xc77LzJOo2LdigS5qZ+gw==}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-ppc64-musl@4.55.1':
|
||||
resolution: {integrity: sha512-ziR1OuZx0vdYZZ30vueNZTg73alF59DicYrPViG0NEgDVN8/Jl87zkAPu4u6VjZST2llgEUjaiNl9JM6HH1Vdw==}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-linux-riscv64-gnu@4.55.1':
|
||||
resolution: {integrity: sha512-uW0Y12ih2XJRERZ4jAfKamTyIHVMPQnTZcQjme2HMVDAHY4amf5u414OqNYC+x+LzRdRcnIG1YodLrrtA8xsxw==}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-riscv64-musl@4.55.1':
|
||||
resolution: {integrity: sha512-u9yZ0jUkOED1BFrqu3BwMQoixvGHGZ+JhJNkNKY/hyoEgOwlqKb62qu+7UjbPSHYjiVy8kKJHvXKv5coH4wDeg==}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-linux-s390x-gnu@4.55.1':
|
||||
resolution: {integrity: sha512-/0PenBCmqM4ZUd0190j7J0UsQ/1nsi735iPRakO8iPciE7BQ495Y6msPzaOmvx0/pn+eJVVlZrNrSh4WSYLxNg==}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-x64-gnu@4.55.1':
|
||||
resolution: {integrity: sha512-a8G4wiQxQG2BAvo+gU6XrReRRqj+pLS2NGXKm8io19goR+K8lw269eTrPkSdDTALwMmJp4th2Uh0D8J9bEV1vg==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-x64-musl@4.55.1':
|
||||
resolution: {integrity: sha512-bD+zjpFrMpP/hqkfEcnjXWHMw5BIghGisOKPj+2NaNDuVT+8Ds4mPf3XcPHuat1tz89WRL+1wbcxKY3WSbiT7w==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-openbsd-x64@4.55.1':
|
||||
resolution: {integrity: sha512-eLXw0dOiqE4QmvikfQ6yjgkg/xDM+MdU9YJuP4ySTibXU0oAvnEWXt7UDJmD4UkYialMfOGFPJnIHSe/kdzPxg==}
|
||||
@@ -2519,6 +2535,9 @@ packages:
|
||||
decimal.js@10.6.0:
|
||||
resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==}
|
||||
|
||||
decorator-transforms@1.2.1:
|
||||
resolution: {integrity: sha512-UUtmyfdlHvYoX3VSG1w5rbvBQ2r5TX1JsE4hmKU9snleFymadA3VACjl6SRfi9YgBCSjBbfQvR1bs9PRW9yBKw==}
|
||||
|
||||
decorator-transforms@2.3.1:
|
||||
resolution: {integrity: sha512-PDOk74Zqqy0946Lx4ckXxbgG6uhPScOICtrxL/pXmfznxchqNee0TaJISClGJQe6FeT8ohGqsOgdjfahm4FwEw==}
|
||||
|
||||
@@ -2669,6 +2688,15 @@ packages:
|
||||
engines: {node: '>= 20.19.0'}
|
||||
hasBin: true
|
||||
|
||||
ember-concurrency@5.2.0:
|
||||
resolution: {integrity: sha512-NUptPzaxaF2XWqn3VQ5KqiLSRqPFIZhWXH3UkOMhiedmiolxGYjUV96maoHWdd5msxNgQBC0UkZ28m7pV7A0sQ==}
|
||||
engines: {node: 16.* || >= 18}
|
||||
peerDependencies:
|
||||
'@glint/template': '>= 1.0.0'
|
||||
peerDependenciesMeta:
|
||||
'@glint/template':
|
||||
optional: true
|
||||
|
||||
ember-eslint-parser@0.5.13:
|
||||
resolution: {integrity: sha512-b6ALDaxs9Bb4v0uagWud/5lECb78qpXHFv7M340dUHFW4Y0RuhlsfA4Rb+765X1+6KHp8G7TaAs0UgggWUqD3g==}
|
||||
engines: {node: '>=16.0.0'}
|
||||
@@ -8110,6 +8138,13 @@ snapshots:
|
||||
|
||||
decimal.js@10.6.0: {}
|
||||
|
||||
decorator-transforms@1.2.1(@babel/core@7.28.6):
|
||||
dependencies:
|
||||
'@babel/plugin-syntax-decorators': 7.28.6(@babel/core@7.28.6)
|
||||
babel-import-util: 2.1.1
|
||||
transitivePeerDependencies:
|
||||
- '@babel/core'
|
||||
|
||||
decorator-transforms@2.3.1(@babel/core@7.28.6):
|
||||
dependencies:
|
||||
'@babel/plugin-syntax-decorators': 7.28.6(@babel/core@7.28.6)
|
||||
@@ -8462,6 +8497,17 @@ snapshots:
|
||||
- walrus
|
||||
- whiskers
|
||||
|
||||
ember-concurrency@5.2.0(@babel/core@7.28.6):
|
||||
dependencies:
|
||||
'@babel/helper-module-imports': 7.28.6
|
||||
'@babel/helper-plugin-utils': 7.28.6
|
||||
'@babel/types': 7.28.6
|
||||
'@embroider/addon-shim': 1.10.2
|
||||
decorator-transforms: 1.2.1(@babel/core@7.28.6)
|
||||
transitivePeerDependencies:
|
||||
- '@babel/core'
|
||||
- supports-color
|
||||
|
||||
ember-eslint-parser@0.5.13(@babel/core@7.28.6)(eslint@9.39.2)(typescript@5.9.3):
|
||||
dependencies:
|
||||
'@babel/core': 7.28.6
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"scope": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#f8f9fa",
|
||||
"theme_color": "#333333",
|
||||
"theme_color": "#2a3743",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/icons/icon-192.png",
|
||||
|
||||
2
release/assets/main-C68xq8aX.js
Normal file
2
release/assets/main-C68xq8aX.js
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
1
release/assets/main-DoLYcE7E.css
Normal file
1
release/assets/main-DoLYcE7E.css
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -3,13 +3,26 @@
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Marco</title>
|
||||
<meta name="description" content="">
|
||||
<meta name="description" content="Unhosted maps app that respects your privacy and choices.">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
|
||||
<!-- Open Graph -->
|
||||
<meta property="og:title" content="Marco">
|
||||
<meta property="og:description" content="Unhosted maps app that respects your privacy and choices.">
|
||||
<meta property="og:type" content="website">
|
||||
<meta property="og:url" content="https://marco.kosmos.org">
|
||||
<meta property="og:image" content="https://marco.kosmos.org/icons/icon-512.png">
|
||||
|
||||
<!-- Twitter Card -->
|
||||
<meta name="twitter:card" content="summary">
|
||||
<meta name="twitter:title" content="Marco">
|
||||
<meta name="twitter:description" content="Unhosted maps app that respects your privacy and choices.">
|
||||
<meta name="twitter:image" content="https://marco.kosmos.org/icons/icon-512.png">
|
||||
|
||||
<!-- App identity -->
|
||||
<meta name="application-name" 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 -->
|
||||
<link rel="manifest" href="/web-app-manifest.json">
|
||||
@@ -26,8 +39,8 @@
|
||||
<meta name="msapplication-TileColor" content="#F6E9A6">
|
||||
<meta name="msapplication-TileImage" content="/icons/icon-144.png">
|
||||
|
||||
<script type="module" crossorigin src="/assets/main-Din37YgL.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/main-iBIZAPnF.css">
|
||||
<script type="module" crossorigin src="/assets/main-C68xq8aX.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/main-DoLYcE7E.css">
|
||||
</head>
|
||||
<body>
|
||||
</body>
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"scope": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#f8f9fa",
|
||||
"theme_color": "#333333",
|
||||
"theme_color": "#2a3743",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/icons/icon-192.png",
|
||||
|
||||
114
tests/acceptance/navigation-test.js
Normal file
114
tests/acceptance/navigation-test.js
Normal file
@@ -0,0 +1,114 @@
|
||||
import { module, test } from 'qunit';
|
||||
import { visit, currentURL, click } 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',
|
||||
};
|
||||
}
|
||||
async fetchOsmObject(id, type) {
|
||||
return {
|
||||
osmId: id,
|
||||
osmType: type,
|
||||
lat: 1,
|
||||
lon: 1,
|
||||
osmTags: { name: 'Test Place', amenity: 'cafe' },
|
||||
title: 'Test Place',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
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');
|
||||
|
||||
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');
|
||||
|
||||
assert.strictEqual(currentURL(), '/', 'Returned to index/map');
|
||||
assert.true(backStub.notCalled, 'window.history.back() was NOT called');
|
||||
} finally {
|
||||
backStub.restore();
|
||||
}
|
||||
});
|
||||
});
|
||||
149
tests/acceptance/search-test.js
Normal file
149
tests/acceptance/search-test.js
Normal file
@@ -0,0 +1,149 @@
|
||||
import { module, test } from 'qunit';
|
||||
import { visit, currentURL } from '@ember/test-helpers';
|
||||
import { setupApplicationTest } from 'marco/tests/helpers';
|
||||
import Service from '@ember/service';
|
||||
|
||||
module('Acceptance | search', function (hooks) {
|
||||
setupApplicationTest(hooks);
|
||||
|
||||
test('visiting /search with q parameter performs text search', async function (assert) {
|
||||
// Mock Photon Service
|
||||
class MockPhotonService extends Service {
|
||||
async search(query) {
|
||||
if (query === 'Berlin') {
|
||||
return [
|
||||
{
|
||||
title: 'Berlin',
|
||||
lat: 52.52,
|
||||
lon: 13.405,
|
||||
osmId: '123',
|
||||
osmType: 'R',
|
||||
description: 'City in Germany',
|
||||
},
|
||||
{
|
||||
title: 'Berlin Alexanderplatz',
|
||||
lat: 52.521,
|
||||
lon: 13.41,
|
||||
osmId: '456',
|
||||
osmType: 'N',
|
||||
description: 'Square in Berlin',
|
||||
},
|
||||
];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
}
|
||||
this.owner.register('service:photon', MockPhotonService);
|
||||
|
||||
// Mock Storage Service (empty)
|
||||
class MockStorageService extends Service {
|
||||
savedPlaces = [];
|
||||
findPlaceById() {
|
||||
return null;
|
||||
}
|
||||
rs = {
|
||||
on: () => {},
|
||||
};
|
||||
// Add placesInView since map component accesses it
|
||||
placesInView = [];
|
||||
loadPlacesInBounds() {
|
||||
return Promise.resolve();
|
||||
}
|
||||
}
|
||||
|
||||
this.owner.register('service:storage', MockStorageService);
|
||||
|
||||
await visit('/search?q=Berlin');
|
||||
|
||||
assert.strictEqual(currentURL(), '/search?q=Berlin');
|
||||
assert.dom('.sidebar-header h2').includesText('Results');
|
||||
assert.dom('.places-list li').exists({ count: 2 });
|
||||
assert.dom('.places-list li:first-child .place-name').hasText('Berlin');
|
||||
});
|
||||
|
||||
test('visiting /search with lat/lon performs nearby search', async function (assert) {
|
||||
// Mock Osm Service
|
||||
class MockOsmService extends Service {
|
||||
async getNearbyPois() {
|
||||
return [
|
||||
{
|
||||
title: 'Nearby Cafe',
|
||||
lat: 52.521,
|
||||
lon: 13.406,
|
||||
osmId: '789',
|
||||
osmType: 'N',
|
||||
_distance: 100, // Pre-calculated or ignored if mocked
|
||||
},
|
||||
];
|
||||
}
|
||||
}
|
||||
this.owner.register('service:osm', MockOsmService);
|
||||
|
||||
// Mock Storage Service (empty)
|
||||
class MockStorageService extends Service {
|
||||
savedPlaces = [];
|
||||
findPlaceById() {
|
||||
return null;
|
||||
}
|
||||
rs = {
|
||||
on: () => {},
|
||||
};
|
||||
// Add placesInView since map component accesses it
|
||||
placesInView = [];
|
||||
loadPlacesInBounds() {
|
||||
return Promise.resolve();
|
||||
}
|
||||
}
|
||||
|
||||
this.owner.register('service:storage', MockStorageService);
|
||||
|
||||
await visit('/search?lat=52.52&lon=13.405');
|
||||
|
||||
assert.strictEqual(currentURL(), '/search?lat=52.52&lon=13.405');
|
||||
assert.dom('.sidebar-header h2').includesText('Nearby');
|
||||
assert.dom('.places-list li').exists({ count: 1 });
|
||||
assert.dom('.places-list li .place-name').hasText('Nearby Cafe');
|
||||
});
|
||||
|
||||
test('local bookmarks are merged into search results', async function (assert) {
|
||||
// Mock Photon Service
|
||||
class MockPhotonService extends Service {
|
||||
async search() {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
this.owner.register('service:photon', MockPhotonService);
|
||||
|
||||
// Mock Storage Service with a bookmark
|
||||
class MockStorageService extends Service {
|
||||
savedPlaces = [
|
||||
{
|
||||
title: 'My Secret Base',
|
||||
lat: 50.0,
|
||||
lon: 10.0,
|
||||
osmId: '999',
|
||||
osmType: 'N',
|
||||
description: 'Top Secret',
|
||||
},
|
||||
];
|
||||
findPlaceById(id) {
|
||||
if (id === '999') return this.savedPlaces[0];
|
||||
return null;
|
||||
}
|
||||
rs = {
|
||||
on: () => {},
|
||||
};
|
||||
placesInView = [];
|
||||
loadPlacesInBounds() {
|
||||
return Promise.resolve();
|
||||
}
|
||||
}
|
||||
this.owner.register('service:storage', MockStorageService);
|
||||
|
||||
await visit('/search?q=Secret');
|
||||
|
||||
assert.strictEqual(currentURL(), '/search?q=Secret');
|
||||
assert.dom('.places-list li').exists({ count: 1 });
|
||||
assert.dom('.places-list li .place-name').hasText('My Secret Base');
|
||||
});
|
||||
});
|
||||
19
tests/integration/components/app-header-test.gjs
Normal file
19
tests/integration/components/app-header-test.gjs
Normal file
@@ -0,0 +1,19 @@
|
||||
import { module, test } from 'qunit';
|
||||
import { setupRenderingTest } from 'marco/tests/helpers';
|
||||
import { render } from '@ember/test-helpers';
|
||||
import AppHeader from 'marco/components/app-header';
|
||||
|
||||
module('Integration | Component | app-header', function (hooks) {
|
||||
setupRenderingTest(hooks);
|
||||
|
||||
test('it renders the search box', async function (assert) {
|
||||
this.noop = () => {};
|
||||
await render(
|
||||
<template><AppHeader @onToggleMenu={{this.noop}} /></template>
|
||||
);
|
||||
|
||||
assert.dom('header.app-header').exists();
|
||||
assert.dom('.search-box').exists('Search box is present in the header');
|
||||
assert.dom('.menu-btn-integrated').exists('Menu button is integrated');
|
||||
});
|
||||
});
|
||||
37
tests/integration/components/place-details-test.gjs
Normal file
37
tests/integration/components/place-details-test.gjs
Normal file
@@ -0,0 +1,37 @@
|
||||
import { module, test } from 'qunit';
|
||||
import { setupRenderingTest } from 'marco/tests/helpers';
|
||||
import { render } from '@ember/test-helpers';
|
||||
import PlaceDetails from 'marco/components/place-details';
|
||||
|
||||
module('Integration | Component | place-details', function (hooks) {
|
||||
setupRenderingTest(hooks);
|
||||
|
||||
test('it formats coordinates correctly', async function (assert) {
|
||||
const place = {
|
||||
title: 'Test Place',
|
||||
lat: 52.520006789,
|
||||
lon: 13.404954123,
|
||||
description: 'A place for testing.',
|
||||
};
|
||||
|
||||
await render(<template><PlaceDetails @place={{place}} /></template>);
|
||||
|
||||
assert.dom('.place-details').exists();
|
||||
assert.dom('.place-details h3').hasText('Test Place');
|
||||
|
||||
// Check for the formatted coordinates link text
|
||||
// "52.520007, 13.404954" (rounded)
|
||||
assert.dom('.meta-info a[href*="geo:"]').hasText('52.520007, 13.404954');
|
||||
});
|
||||
|
||||
test('it handles missing coordinates gracefully', async function (assert) {
|
||||
const place = {
|
||||
title: 'Place without Coords',
|
||||
};
|
||||
|
||||
await render(<template><PlaceDetails @place={{place}} /></template>);
|
||||
|
||||
assert.dom('.place-details h3').hasText('Place without Coords');
|
||||
assert.dom('.meta-info a[href*="geo:"]').doesNotExist();
|
||||
});
|
||||
});
|
||||
137
tests/integration/components/search-box-test.gjs
Normal file
137
tests/integration/components/search-box-test.gjs
Normal file
@@ -0,0 +1,137 @@
|
||||
import { module, test } from 'qunit';
|
||||
import { setupRenderingTest } from 'marco/tests/helpers';
|
||||
import { render, fillIn, click, waitFor } from '@ember/test-helpers';
|
||||
import SearchBox from 'marco/components/search-box';
|
||||
import Service from '@ember/service';
|
||||
|
||||
module('Integration | Component | search-box', function (hooks) {
|
||||
setupRenderingTest(hooks);
|
||||
|
||||
test('it renders and handles search input', async function (assert) {
|
||||
// Mock Photon Service
|
||||
class MockPhotonService extends Service {
|
||||
async search(query) {
|
||||
if (query === 'test') {
|
||||
return [
|
||||
{
|
||||
title: 'Test Place',
|
||||
description: 'A test description',
|
||||
lat: 10,
|
||||
lon: 20,
|
||||
osmId: '123',
|
||||
osmType: 'node',
|
||||
},
|
||||
];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
}
|
||||
this.owner.register('service:photon', MockPhotonService);
|
||||
|
||||
// Mock Router Service
|
||||
class MockRouterService extends Service {
|
||||
transitionTo(routeName, ...args) {
|
||||
assert.step(`transitionTo: ${routeName} ${JSON.stringify(args)}`);
|
||||
}
|
||||
}
|
||||
this.owner.register('service:router', MockRouterService);
|
||||
|
||||
this.noop = () => {};
|
||||
await render(
|
||||
<template><SearchBox @onToggleMenu={{this.noop}} /></template>
|
||||
);
|
||||
|
||||
assert.dom('.search-input').exists();
|
||||
assert.dom('.search-results-popover').doesNotExist();
|
||||
|
||||
// Type 'test'
|
||||
await fillIn('.search-input', 'test');
|
||||
|
||||
// Wait for debounce and async search
|
||||
await waitFor('.search-results-popover', { timeout: 2000 });
|
||||
|
||||
assert.dom('.search-result-item').exists({ count: 1 });
|
||||
assert.dom('.result-title').hasText('Test Place');
|
||||
assert.dom('.result-desc').hasText('A test description');
|
||||
|
||||
// Click result
|
||||
await click('.search-result-item');
|
||||
|
||||
assert.verifySteps(['transitionTo: place ["osm:node:123"]']);
|
||||
assert
|
||||
.dom('.search-results-popover')
|
||||
.doesNotExist('Popover closes after selection');
|
||||
});
|
||||
|
||||
test('it handles submit for full search', async function (assert) {
|
||||
// Mock Photon Service
|
||||
class MockPhotonService extends Service {
|
||||
async search() {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
this.owner.register('service:photon', MockPhotonService);
|
||||
|
||||
// Mock MapUi Service
|
||||
class MockMapUiService extends Service {
|
||||
currentCenter = { lat: 52.52, lon: 13.405 };
|
||||
setSearchBoxFocus() {}
|
||||
}
|
||||
this.owner.register('service:map-ui', MockMapUiService);
|
||||
|
||||
// Mock Router Service
|
||||
class MockRouterService extends Service {
|
||||
transitionTo(routeName, options) {
|
||||
assert.step(`transitionTo: ${routeName} ${JSON.stringify(options)}`);
|
||||
}
|
||||
}
|
||||
this.owner.register('service:router', MockRouterService);
|
||||
|
||||
this.noop = () => {};
|
||||
await render(
|
||||
<template><SearchBox @onToggleMenu={{this.noop}} /></template>
|
||||
);
|
||||
|
||||
await fillIn('.search-input', 'berlin');
|
||||
await click('.search-input'); // Focus
|
||||
// Trigger submit event on the form
|
||||
await this.element
|
||||
.querySelector('form')
|
||||
.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));
|
||||
|
||||
assert.verifySteps([
|
||||
'transitionTo: search {"queryParams":{"q":"berlin","selected":null,"lat":"52.5200","lon":"13.4050"}}',
|
||||
]);
|
||||
});
|
||||
|
||||
test('it uses map center for biased search', async function (assert) {
|
||||
// Mock MapUi Service
|
||||
class MockMapUiService extends Service {
|
||||
currentCenter = { lat: 52.52, lon: 13.405 };
|
||||
setSearchBoxFocus() {}
|
||||
}
|
||||
this.owner.register('service:map-ui', MockMapUiService);
|
||||
|
||||
// Mock Photon Service
|
||||
class MockPhotonService extends Service {
|
||||
async search(query, lat, lon) {
|
||||
assert.step(`search: ${query}, ${lat}, ${lon}`);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
this.owner.register('service:photon', MockPhotonService);
|
||||
|
||||
this.noop = () => {};
|
||||
await render(
|
||||
<template><SearchBox @onToggleMenu={{this.noop}} /></template>
|
||||
);
|
||||
|
||||
await fillIn('.search-input', 'cafe');
|
||||
|
||||
// Wait for debounce (300ms) + execution
|
||||
const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
await delay(400);
|
||||
|
||||
assert.verifySteps(['search: cafe, 52.52, 13.405']);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
import { module, test } from 'qunit';
|
||||
import { setupTest } from 'marco/tests/helpers';
|
||||
import Service from '@ember/service';
|
||||
|
||||
module('Unit | Route | place', function (hooks) {
|
||||
setupTest(hooks);
|
||||
@@ -8,4 +9,120 @@ module('Unit | Route | place', function (hooks) {
|
||||
let route = this.owner.lookup('route:place');
|
||||
assert.ok(route);
|
||||
});
|
||||
|
||||
test('afterModel enriches model with missing geometry', async function (assert) {
|
||||
let route = this.owner.lookup('route:place');
|
||||
|
||||
// Mock Services
|
||||
let fetchCalled = false;
|
||||
let selectPlaceCalled = false;
|
||||
|
||||
class OsmStub extends Service {
|
||||
async fetchOsmObject(id, type) {
|
||||
fetchCalled = true;
|
||||
assert.strictEqual(id, '123', 'Correct ID passed');
|
||||
assert.strictEqual(type, 'way', 'Correct Type passed');
|
||||
return {
|
||||
osmId: '123',
|
||||
osmType: 'way',
|
||||
geojson: { type: 'Polygon', coordinates: [] },
|
||||
tags: { updated: 'true' },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
class MapUiStub extends Service {
|
||||
selectPlace() {
|
||||
selectPlaceCalled = true;
|
||||
}
|
||||
stopSearch() {}
|
||||
}
|
||||
|
||||
this.owner.register('service:osm', OsmStub);
|
||||
this.owner.register('service:map-ui', MapUiStub);
|
||||
|
||||
// Initial partial model (from search)
|
||||
let model = {
|
||||
osmId: '123',
|
||||
osmType: 'way',
|
||||
title: 'Partial Place',
|
||||
// No geojson
|
||||
};
|
||||
|
||||
await route.afterModel(model);
|
||||
|
||||
assert.ok(fetchCalled, 'fetchOsmObject should be called');
|
||||
assert.ok(selectPlaceCalled, 'selectPlace should be called');
|
||||
assert.ok(model.geojson, 'Model should now have geojson');
|
||||
assert.strictEqual(
|
||||
model.tags.updated,
|
||||
'true',
|
||||
'Model should have updated tags'
|
||||
);
|
||||
});
|
||||
|
||||
test('afterModel skips fetch if geometry exists', async function (assert) {
|
||||
let route = this.owner.lookup('route:place');
|
||||
|
||||
let fetchCalled = false;
|
||||
|
||||
class OsmStub extends Service {
|
||||
async fetchOsmObject() {
|
||||
fetchCalled = true;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
class MapUiStub extends Service {
|
||||
selectPlace() {}
|
||||
stopSearch() {}
|
||||
}
|
||||
|
||||
this.owner.register('service:osm', OsmStub);
|
||||
this.owner.register('service:map-ui', MapUiStub);
|
||||
|
||||
let model = {
|
||||
osmId: '456',
|
||||
osmType: 'relation',
|
||||
geojson: { type: 'MultiLineString' },
|
||||
};
|
||||
|
||||
await route.afterModel(model);
|
||||
|
||||
assert.notOk(
|
||||
fetchCalled,
|
||||
'fetchOsmObject should NOT be called if geojson exists'
|
||||
);
|
||||
});
|
||||
|
||||
test('afterModel skips fetch for nodes even if geometry is missing', async function (assert) {
|
||||
let route = this.owner.lookup('route:place');
|
||||
|
||||
let fetchCalled = false;
|
||||
|
||||
class OsmStub extends Service {
|
||||
async fetchOsmObject() {
|
||||
fetchCalled = true;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
class MapUiStub extends Service {
|
||||
selectPlace() {}
|
||||
stopSearch() {}
|
||||
}
|
||||
|
||||
this.owner.register('service:osm', OsmStub);
|
||||
this.owner.register('service:map-ui', MapUiStub);
|
||||
|
||||
let model = {
|
||||
osmId: '789',
|
||||
osmType: 'node',
|
||||
// No geojson, but it's a node
|
||||
};
|
||||
|
||||
await route.afterModel(model);
|
||||
|
||||
assert.notOk(fetchCalled, 'fetchOsmObject should NOT be called for nodes');
|
||||
});
|
||||
});
|
||||
|
||||
254
tests/unit/services/osm-test.js
Normal file
254
tests/unit/services/osm-test.js
Normal file
@@ -0,0 +1,254 @@
|
||||
import { module, test } from 'qunit';
|
||||
import { setupTest } from 'marco/tests/helpers';
|
||||
|
||||
module('Unit | Service | osm', function (hooks) {
|
||||
setupTest(hooks);
|
||||
|
||||
test('it exists', function (assert) {
|
||||
let service = this.owner.lookup('service:osm');
|
||||
assert.ok(service);
|
||||
});
|
||||
|
||||
test('normalizeOsmApiData handles nodes correctly', function (assert) {
|
||||
let service = this.owner.lookup('service:osm');
|
||||
const elements = [
|
||||
{
|
||||
id: 123,
|
||||
type: 'node',
|
||||
lat: 52.5,
|
||||
lon: 13.4,
|
||||
tags: { name: 'Test Node' },
|
||||
},
|
||||
];
|
||||
|
||||
const result = service.normalizeOsmApiData(elements, 123, 'node');
|
||||
|
||||
assert.strictEqual(result.title, 'Test Node');
|
||||
assert.strictEqual(result.lat, 52.5);
|
||||
assert.strictEqual(result.lon, 13.4);
|
||||
assert.strictEqual(result.osmId, '123');
|
||||
assert.strictEqual(result.osmType, 'node');
|
||||
});
|
||||
|
||||
test('normalizeOsmApiData calculates centroid for ways', function (assert) {
|
||||
let service = this.owner.lookup('service:osm');
|
||||
const elements = [
|
||||
{
|
||||
id: 456,
|
||||
type: 'way',
|
||||
nodes: [1, 2],
|
||||
tags: { name: 'Test Way' },
|
||||
},
|
||||
{ id: 1, type: 'node', lat: 10, lon: 10 },
|
||||
{ id: 2, type: 'node', lat: 20, lon: 20 },
|
||||
];
|
||||
|
||||
const result = service.normalizeOsmApiData(elements, 456, 'way');
|
||||
|
||||
assert.strictEqual(result.title, 'Test Way');
|
||||
assert.strictEqual(result.lat, 15); // (10+20)/2
|
||||
assert.strictEqual(result.lon, 15); // (10+20)/2
|
||||
assert.strictEqual(result.osmId, '456');
|
||||
assert.strictEqual(result.osmType, 'way');
|
||||
});
|
||||
|
||||
test('normalizeOsmApiData prioritizes label node for relations', function (assert) {
|
||||
let service = this.owner.lookup('service:osm');
|
||||
const elements = [
|
||||
{
|
||||
id: 789,
|
||||
type: 'relation',
|
||||
members: [
|
||||
{ type: 'node', ref: 1, role: 'admin_centre' },
|
||||
{ type: 'node', ref: 2, role: 'label' },
|
||||
],
|
||||
tags: { name: 'Test Relation' },
|
||||
},
|
||||
{ id: 1, type: 'node', lat: 10, lon: 10, tags: { name: 'Admin Centre' } },
|
||||
{ id: 2, type: 'node', lat: 30, lon: 30, tags: { name: 'Label Node' } },
|
||||
];
|
||||
|
||||
const result = service.normalizeOsmApiData(elements, 789, 'relation');
|
||||
|
||||
assert.strictEqual(result.title, 'Label Node');
|
||||
assert.strictEqual(result.lat, 30);
|
||||
assert.strictEqual(result.lon, 30);
|
||||
assert.strictEqual(result.osmId, '2');
|
||||
assert.strictEqual(result.osmType, 'node');
|
||||
});
|
||||
|
||||
test('normalizeOsmApiData falls back to admin_centre node for relations', function (assert) {
|
||||
let service = this.owner.lookup('service:osm');
|
||||
const elements = [
|
||||
{
|
||||
id: 789,
|
||||
type: 'relation',
|
||||
members: [{ type: 'node', ref: 1, role: 'admin_centre' }],
|
||||
tags: { name: 'Test Relation' },
|
||||
},
|
||||
{ id: 1, type: 'node', lat: 10, lon: 10, tags: { name: 'Admin Centre' } },
|
||||
];
|
||||
|
||||
const result = service.normalizeOsmApiData(elements, 789, 'relation');
|
||||
|
||||
assert.strictEqual(result.title, 'Admin Centre');
|
||||
assert.strictEqual(result.lat, 10);
|
||||
assert.strictEqual(result.lon, 10);
|
||||
assert.strictEqual(result.osmId, '1');
|
||||
assert.strictEqual(result.osmType, 'node');
|
||||
});
|
||||
|
||||
test('normalizeOsmApiData calculates bbox for relations', function (assert) {
|
||||
let service = this.owner.lookup('service:osm');
|
||||
const elements = [
|
||||
{
|
||||
id: 789,
|
||||
type: 'relation',
|
||||
members: [
|
||||
{ type: 'node', ref: 1, role: 'label' },
|
||||
{ type: 'node', ref: 2, role: 'border' },
|
||||
{ type: 'node', ref: 3, role: 'border' },
|
||||
],
|
||||
tags: { name: 'Test Relation' },
|
||||
},
|
||||
{ id: 1, type: 'node', lat: 10, lon: 10, tags: { name: 'Label' } },
|
||||
{ id: 2, type: 'node', lat: 0, lon: 0 },
|
||||
{ id: 3, type: 'node', lat: 20, lon: 20 },
|
||||
];
|
||||
|
||||
const result = service.normalizeOsmApiData(elements, 789, 'relation');
|
||||
|
||||
// Should prioritize admin centre for ID/Title/Center
|
||||
assert.strictEqual(result.title, 'Label');
|
||||
assert.strictEqual(result.lat, 10);
|
||||
assert.strictEqual(result.lon, 10);
|
||||
assert.strictEqual(result.osmId, '1');
|
||||
assert.strictEqual(result.osmType, 'node');
|
||||
|
||||
// BUT should calculate BBox from ALL members (0,0 to 20,20)
|
||||
assert.ok(result.bbox, 'BBox should be present');
|
||||
assert.strictEqual(result.bbox.minLat, 0);
|
||||
assert.strictEqual(result.bbox.minLon, 0);
|
||||
assert.strictEqual(result.bbox.maxLat, 20);
|
||||
assert.strictEqual(result.bbox.maxLon, 20);
|
||||
});
|
||||
|
||||
test('normalizeOsmApiData calculates centroid for relations with member ways', function (assert) {
|
||||
let service = this.owner.lookup('service:osm');
|
||||
/*
|
||||
Relation 999
|
||||
-> Way 888
|
||||
-> Node 1 (10, 10)
|
||||
-> Node 2 (20, 20)
|
||||
*/
|
||||
const elements = [
|
||||
{
|
||||
id: 999,
|
||||
type: 'relation',
|
||||
members: [{ type: 'way', ref: 888, role: 'outer' }],
|
||||
tags: { name: 'Complex Relation' },
|
||||
},
|
||||
{
|
||||
id: 888,
|
||||
type: 'way',
|
||||
nodes: [1, 2],
|
||||
},
|
||||
{ id: 1, type: 'node', lat: 10, lon: 10 },
|
||||
{ id: 2, type: 'node', lat: 20, lon: 20 },
|
||||
];
|
||||
|
||||
const result = service.normalizeOsmApiData(elements, 999, 'relation');
|
||||
|
||||
assert.strictEqual(result.title, 'Complex Relation');
|
||||
// It averages all nodes found. In this case, Node 1 and Node 2.
|
||||
assert.strictEqual(result.lat, 15); // (10+20)/2
|
||||
assert.strictEqual(result.lon, 15); // (10+20)/2
|
||||
assert.strictEqual(result.osmId, '999');
|
||||
assert.strictEqual(result.osmType, 'relation');
|
||||
});
|
||||
|
||||
test('normalizeOsmApiData creates GeoJSON for ways', function (assert) {
|
||||
let service = this.owner.lookup('service:osm');
|
||||
const elements = [
|
||||
{
|
||||
id: 456,
|
||||
type: 'way',
|
||||
nodes: [1, 2, 3],
|
||||
tags: { name: 'Test Way' },
|
||||
},
|
||||
{ id: 1, type: 'node', lat: 0, lon: 0 },
|
||||
{ id: 2, type: 'node', lat: 10, lon: 10 },
|
||||
{ id: 3, type: 'node', lat: 0, lon: 0 }, // Closed loop
|
||||
];
|
||||
|
||||
const result = service.normalizeOsmApiData(elements, 456, 'way');
|
||||
|
||||
assert.ok(result.geojson, 'GeoJSON should be present');
|
||||
assert.strictEqual(
|
||||
result.geojson.type,
|
||||
'Polygon',
|
||||
'Closed way should be a Polygon'
|
||||
);
|
||||
assert.strictEqual(
|
||||
result.geojson.coordinates[0].length,
|
||||
3,
|
||||
'Should have 3 coordinates'
|
||||
);
|
||||
assert.deepEqual(result.geojson.coordinates[0][0], [0, 0]);
|
||||
assert.deepEqual(result.geojson.coordinates[0][1], [10, 10]);
|
||||
});
|
||||
|
||||
test('normalizeOsmApiData creates GeoJSON MultiLineString for relations', function (assert) {
|
||||
let service = this.owner.lookup('service:osm');
|
||||
/*
|
||||
Relation 999
|
||||
-> Way 888 (0,0 -> 10,10)
|
||||
-> Way 777 (20,20 -> 30,30)
|
||||
*/
|
||||
const elements = [
|
||||
{
|
||||
id: 999,
|
||||
type: 'relation',
|
||||
members: [
|
||||
{ type: 'way', ref: 888, role: 'outer' },
|
||||
{ type: 'way', ref: 777, role: 'inner' },
|
||||
],
|
||||
tags: { name: 'Complex Relation' },
|
||||
},
|
||||
{
|
||||
id: 888,
|
||||
type: 'way',
|
||||
nodes: [1, 2],
|
||||
},
|
||||
{
|
||||
id: 777,
|
||||
type: 'way',
|
||||
nodes: [3, 4],
|
||||
},
|
||||
{ id: 1, type: 'node', lat: 0, lon: 0 },
|
||||
{ id: 2, type: 'node', lat: 10, lon: 10 },
|
||||
{ id: 3, type: 'node', lat: 20, lon: 20 },
|
||||
{ id: 4, type: 'node', lat: 30, lon: 30 },
|
||||
];
|
||||
|
||||
const result = service.normalizeOsmApiData(elements, 999, 'relation');
|
||||
|
||||
assert.ok(result.geojson, 'GeoJSON should be present');
|
||||
assert.strictEqual(result.geojson.type, 'MultiLineString');
|
||||
assert.strictEqual(
|
||||
result.geojson.coordinates.length,
|
||||
2,
|
||||
'Should have 2 segments'
|
||||
);
|
||||
// Check first segment (Way 888)
|
||||
assert.deepEqual(result.geojson.coordinates[0], [
|
||||
[0, 0],
|
||||
[10, 10],
|
||||
]);
|
||||
// Check second segment (Way 777)
|
||||
assert.deepEqual(result.geojson.coordinates[1], [
|
||||
[20, 20],
|
||||
[30, 30],
|
||||
]);
|
||||
});
|
||||
});
|
||||
137
tests/unit/services/photon-test.js
Normal file
137
tests/unit/services/photon-test.js
Normal file
@@ -0,0 +1,137 @@
|
||||
import { module, test } from 'qunit';
|
||||
import { setupTest } from 'marco/tests/helpers';
|
||||
|
||||
module('Unit | Service | photon', function (hooks) {
|
||||
setupTest(hooks);
|
||||
|
||||
test('it exists', function (assert) {
|
||||
let service = this.owner.lookup('service:photon');
|
||||
assert.ok(service);
|
||||
});
|
||||
|
||||
test('search truncates coordinates to 4 decimal places', async function (assert) {
|
||||
let service = this.owner.lookup('service:photon');
|
||||
const originalFetch = window.fetch;
|
||||
|
||||
let capturedUrl;
|
||||
window.fetch = async (url) => {
|
||||
capturedUrl = url;
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({ features: [] }),
|
||||
};
|
||||
};
|
||||
|
||||
try {
|
||||
await service.search('Test', 52.123456, 13.987654);
|
||||
assert.ok(
|
||||
capturedUrl.includes('lat=52.1235'),
|
||||
'lat is rounded to 4 decimals'
|
||||
);
|
||||
assert.ok(
|
||||
capturedUrl.includes('lon=13.9877'),
|
||||
'lon is rounded to 4 decimals'
|
||||
);
|
||||
} finally {
|
||||
window.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test('search handles successful response', async function (assert) {
|
||||
let service = this.owner.lookup('service:photon');
|
||||
|
||||
// Mock fetch
|
||||
const originalFetch = window.fetch;
|
||||
window.fetch = async () => {
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
features: [
|
||||
{
|
||||
properties: {
|
||||
name: 'Test Place',
|
||||
osm_id: 123,
|
||||
osm_type: 'N',
|
||||
city: 'Test City',
|
||||
country: 'Test Country',
|
||||
},
|
||||
geometry: {
|
||||
coordinates: [13.4, 52.5], // lon, lat
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
try {
|
||||
const results = await service.search('Test', 52.5, 13.4);
|
||||
assert.strictEqual(results.length, 1);
|
||||
assert.strictEqual(results[0].title, 'Test Place');
|
||||
assert.strictEqual(results[0].lat, 52.5);
|
||||
assert.strictEqual(results[0].lon, 13.4);
|
||||
assert.strictEqual(results[0].description, 'Test City, Test Country');
|
||||
assert.strictEqual(results[0].osmType, 'node', 'Normalizes N to node');
|
||||
} finally {
|
||||
window.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test('search handles empty response', async function (assert) {
|
||||
let service = this.owner.lookup('service:photon');
|
||||
|
||||
// Mock fetch
|
||||
const originalFetch = window.fetch;
|
||||
window.fetch = async () => {
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({ features: [] }),
|
||||
};
|
||||
};
|
||||
|
||||
try {
|
||||
const results = await service.search('Nonexistent', 52.5, 13.4);
|
||||
assert.strictEqual(results.length, 0);
|
||||
} finally {
|
||||
window.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test('normalizeFeature handles missing properties', function (assert) {
|
||||
let service = this.owner.lookup('service:photon');
|
||||
|
||||
const feature = {
|
||||
properties: {
|
||||
street: 'Main St',
|
||||
housenumber: '123',
|
||||
city: 'Metropolis',
|
||||
},
|
||||
geometry: {
|
||||
coordinates: [10, 20],
|
||||
},
|
||||
};
|
||||
|
||||
const result = service.normalizeFeature(feature);
|
||||
assert.strictEqual(result.title, 'Main St 123, Metropolis'); // Fallback to address description
|
||||
assert.strictEqual(result.lat, 20);
|
||||
assert.strictEqual(result.lon, 10);
|
||||
});
|
||||
|
||||
test('normalizeFeature normalizes OSM types correctly', function (assert) {
|
||||
let service = this.owner.lookup('service:photon');
|
||||
|
||||
const checkType = (input, expected) => {
|
||||
const feature = {
|
||||
properties: { osm_type: input, name: 'Test' },
|
||||
geometry: { coordinates: [0, 0] },
|
||||
};
|
||||
const result = service.normalizeFeature(feature);
|
||||
assert.strictEqual(result.osmType, expected, `${input} -> ${expected}`);
|
||||
};
|
||||
|
||||
checkType('N', 'node');
|
||||
checkType('W', 'way');
|
||||
checkType('R', 'relation');
|
||||
checkType('unknown', 'unknown'); // Fallback
|
||||
});
|
||||
});
|
||||
104
tests/unit/utils/osm-test.js
Normal file
104
tests/unit/utils/osm-test.js
Normal file
@@ -0,0 +1,104 @@
|
||||
import { module, test } from 'qunit';
|
||||
import { setupTest } from 'marco/tests/helpers';
|
||||
import { getLocalizedName, getPlaceType } from 'marco/utils/osm';
|
||||
|
||||
module('Unit | Utility | osm', function (hooks) {
|
||||
setupTest(hooks);
|
||||
|
||||
test('getLocalizedName returns default name if tags are missing', function (assert) {
|
||||
const result = getLocalizedName(null);
|
||||
assert.strictEqual(result, 'Untitled Place');
|
||||
});
|
||||
|
||||
test('getLocalizedName returns name tag', function (assert) {
|
||||
const tags = { name: 'Foo' };
|
||||
const result = getLocalizedName(tags);
|
||||
assert.strictEqual(result, 'Foo');
|
||||
});
|
||||
|
||||
test('getLocalizedName falls back to name:en if name is missing', function (assert) {
|
||||
const tags = { 'name:en': 'English Name' };
|
||||
const result = getLocalizedName(tags);
|
||||
assert.strictEqual(result, 'English Name');
|
||||
});
|
||||
|
||||
test('getLocalizedName returns local name (name tag) if no preferred language match found', function (assert) {
|
||||
// Assuming the test environment doesn't have 'fr' as a preferred language
|
||||
const tags = { name: 'Local Name', 'name:fr': 'French Name' };
|
||||
// Temporarily mock navigator to ensure no match
|
||||
const originalLanguages = navigator.languages;
|
||||
const originalLanguage = navigator.language;
|
||||
Object.defineProperty(navigator, 'languages', {
|
||||
value: ['es'],
|
||||
configurable: true,
|
||||
});
|
||||
Object.defineProperty(navigator, 'language', {
|
||||
value: 'es',
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
try {
|
||||
const result = getLocalizedName(tags);
|
||||
assert.strictEqual(result, 'Local Name');
|
||||
} finally {
|
||||
// Restore
|
||||
Object.defineProperty(navigator, 'languages', {
|
||||
value: originalLanguages,
|
||||
configurable: true,
|
||||
});
|
||||
Object.defineProperty(navigator, 'language', {
|
||||
value: originalLanguage,
|
||||
configurable: true,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test('getLocalizedName matches user preferred language', function (assert) {
|
||||
const tags = {
|
||||
name: 'Standard Name',
|
||||
'name:de': 'Deutscher Name',
|
||||
'name:fr': 'Nom Français',
|
||||
};
|
||||
|
||||
const originalLanguages = navigator.languages;
|
||||
Object.defineProperty(navigator, 'languages', {
|
||||
value: ['de', 'en'],
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
try {
|
||||
const result = getLocalizedName(tags);
|
||||
assert.strictEqual(result, 'Deutscher Name');
|
||||
} finally {
|
||||
Object.defineProperty(navigator, 'languages', {
|
||||
value: originalLanguages,
|
||||
configurable: true,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test('getPlaceType returns value for normal tags', function (assert) {
|
||||
const tags = { amenity: 'restaurant' };
|
||||
const result = getPlaceType(tags);
|
||||
assert.strictEqual(result, 'Restaurant');
|
||||
});
|
||||
|
||||
test('getPlaceType returns key name if value is "yes"', function (assert) {
|
||||
const tags = { building: 'yes' };
|
||||
const result = getPlaceType(tags);
|
||||
assert.strictEqual(result, 'Building');
|
||||
});
|
||||
|
||||
test('getPlaceType prioritizes order (amenity > shop > building)', function (assert) {
|
||||
// If something is both a shop and a building, it should be a shop
|
||||
const tags = { building: 'yes', shop: 'supermarket' };
|
||||
const result = getPlaceType(tags);
|
||||
assert.strictEqual(result, 'Supermarket');
|
||||
});
|
||||
|
||||
test('getPlaceType returns null if no known type found', function (assert) {
|
||||
const tags = { foo: 'bar' };
|
||||
const result = getPlaceType(tags);
|
||||
assert.strictEqual(result, null);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user