Compare commits
23 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
20f63065ad
|
|||
|
39a7ec3595
|
|||
| 32dfa3a30f | |||
|
64ccc694d3
|
|||
|
87e2380ef6
|
|||
| 66c31b19f1 | |||
|
55aecbd699
|
|||
|
ccaa56b78f
|
|||
|
d30375707a
|
|||
|
53300b92f5
|
|||
|
c37f794eea
|
|||
|
4bc92bb7cc
|
|||
|
9f48d7b264
|
|||
| bbd3bf47c6 | |||
|
59e3d91071
|
|||
|
348b721876
|
|||
|
3d982a6a7c
|
|||
|
0af9d9f16d
|
|||
|
a0f132ec64
|
|||
|
925f26ae5d
|
|||
|
58bb8831f3
|
|||
|
585837cae7
|
|||
|
42c5282844
|
@@ -92,6 +92,17 @@ We are building **Marco**, a decentralized maps application using **Ember.js** (
|
|||||||
- **Smart Linking:** The `showPlaces` action intercepts search results and automatically resolves them to existing **Bookmarks** if a match is found (via `storage.findPlaceById`). This ensures the app navigates to the persistent Bookmark URL (ULID) and correctly reflects the "Saved" status in the UI instead of treating it as a new generic OSM place.
|
- **Smart Linking:** The `showPlaces` action intercepts search results and automatically resolves them to existing **Bookmarks** if a match is found (via `storage.findPlaceById`). This ensures the app navigates to the persistent Bookmark URL (ULID) and correctly reflects the "Saved" status in the UI instead of treating it as a new generic OSM place.
|
||||||
- **Data Normalization:** Refactored `OsmService` to return normalized objects (`osmTags`, `osmType`) for all queries. This ensures consistent data structures between fresh Overpass results and saved bookmarks throughout the app.
|
- **Data Normalization:** Refactored `OsmService` to return normalized objects (`osmTags`, `osmType`) for all queries. This ensures consistent data structures between fresh Overpass results and saved bookmarks throughout the app.
|
||||||
|
|
||||||
|
### 5. Creation & Editing Workflow
|
||||||
|
|
||||||
|
- **Create Place:**
|
||||||
|
- Implemented `/place/new` route for creating new private places.
|
||||||
|
- **UX:** Map displays a central crosshair for precise location selection.
|
||||||
|
- **Mobile Optimization:**
|
||||||
|
- Disabled map inertia (`kinetic: false`) to ensure the map stops exactly where the finger releases.
|
||||||
|
- `PlaceEditForm` conditionally disables autofocus on mobile screens (`<= 768px`) to prevent the onscreen keyboard from obscuring the map view immediately.
|
||||||
|
- Responsive crosshair sizing (48px desktop / 24px mobile).
|
||||||
|
- **Persistence:** Form data (Title, Description) and Map coordinates are securely saved to RemoteStorage via `storage.storePlace`.
|
||||||
|
|
||||||
## Current State
|
## Current State
|
||||||
|
|
||||||
- **Repo:** The app runs via `pnpm start`.
|
- **Repo:** The app runs via `pnpm start`.
|
||||||
@@ -102,20 +113,20 @@ We are building **Marco**, a decentralized maps application using **Ember.js** (
|
|||||||
- If direct match: Redirect to `/place/:id`.
|
- If direct match: Redirect to `/place/:id`.
|
||||||
- If multiple results: Show `/search` list view.
|
- If multiple results: Show `/search` list view.
|
||||||
4. Sidebar displays details via `<PlaceDetails>` component (Bottom sheet on mobile).
|
4. Sidebar displays details via `<PlaceDetails>` component (Bottom sheet on mobile).
|
||||||
5. User clicks "Save Bookmark" -> Stores JSON in RemoteStorage.
|
5. **Creation:** User clicks "Create Place" -> Enters creation mode (crosshair) -> Positions map -> Enters details -> Save.
|
||||||
6. RemoteStorage change event -> Debounced reload updates the map reactive-ly.
|
6. **Persistence:** RemoteStorage change event -> Debounced reload updates the map reactive-ly.
|
||||||
7. **Editing:** User can edit the Title and Description of saved bookmarks via an "Edit" button in the details view.
|
7. **Editing:** User can edit the Title and Description of saved bookmarks via an "Edit" button in the details view.
|
||||||
8. **Settings:** User can change the Overpass API provider via the new Settings menu.
|
8. **Settings:** User can change the Overpass API provider via the new Settings menu.
|
||||||
|
|
||||||
## Files Currently in Focus
|
## Files Currently in Focus
|
||||||
|
|
||||||
- `app/services/osm.js`: Caching logic.
|
- `app/components/map.gjs`
|
||||||
- `app/routes/search.js`: Search heuristics.
|
- `app/components/place-edit-form.gjs`
|
||||||
- `app/components/place-details.gjs`: Formatting logic.
|
- `app/templates/place/new.gjs`
|
||||||
|
|
||||||
## Next Steps & Pending Tasks
|
## Next Steps & Pending Tasks
|
||||||
|
|
||||||
1. **Linting & Code Quality:** Fix remaining CSS errors, remove inline styles in `map.gjs`, and address unused variables/runloop usage.
|
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.
|
2. **Testing:** Add automated tests for the geohash coverage, retry logic, and new editing features.
|
||||||
3. **Performance:** Monitor performance with large datasets (thousands of bookmarks).
|
3. **Performance:** Monitor performance with large datasets (thousands of bookmarks).
|
||||||
|
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ export default class AppHeaderComponent extends Component {
|
|||||||
<header class="app-header">
|
<header class="app-header">
|
||||||
<div class="header-left">
|
<div class="header-left">
|
||||||
<button
|
<button
|
||||||
class="icon-btn"
|
class="menu-btn btn-press"
|
||||||
type="button"
|
type="button"
|
||||||
aria-label="Menu"
|
aria-label="Menu"
|
||||||
{{on "click" @onToggleMenu}}
|
{{on "click" @onToggleMenu}}
|
||||||
@@ -36,7 +36,7 @@ export default class AppHeaderComponent extends Component {
|
|||||||
<div class="header-right">
|
<div class="header-right">
|
||||||
<div class="user-menu-container">
|
<div class="user-menu-container">
|
||||||
<button
|
<button
|
||||||
class="user-btn"
|
class="user-btn btn-press"
|
||||||
type="button"
|
type="button"
|
||||||
aria-label="User Menu"
|
aria-label="User Menu"
|
||||||
{{on "click" this.toggleUserMenu}}
|
{{on "click" this.toggleUserMenu}}
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import { modifier } from 'ember-modifier';
|
|||||||
import 'ol/ol.css';
|
import 'ol/ol.css';
|
||||||
import Map from 'ol/Map.js';
|
import Map from 'ol/Map.js';
|
||||||
import { defaults as defaultControls, Control } from 'ol/control.js';
|
import { defaults as defaultControls, Control } from 'ol/control.js';
|
||||||
|
import { defaults as defaultInteractions, DragPan } from 'ol/interaction.js';
|
||||||
|
import Kinetic from 'ol/Kinetic.js';
|
||||||
import View from 'ol/View.js';
|
import View from 'ol/View.js';
|
||||||
import { fromLonLat, toLonLat, getPointResolution } from 'ol/proj.js';
|
import { fromLonLat, toLonLat, getPointResolution } from 'ol/proj.js';
|
||||||
import Overlay from 'ol/Overlay.js';
|
import Overlay from 'ol/Overlay.js';
|
||||||
@@ -21,6 +23,7 @@ export default class MapComponent extends Component {
|
|||||||
@service storage;
|
@service storage;
|
||||||
@service mapUi;
|
@service mapUi;
|
||||||
@service router;
|
@service router;
|
||||||
|
@service settings;
|
||||||
|
|
||||||
mapInstance;
|
mapInstance;
|
||||||
bookmarkSource;
|
bookmarkSource;
|
||||||
@@ -65,6 +68,7 @@ export default class MapComponent extends Component {
|
|||||||
// Default view settings
|
// Default view settings
|
||||||
let center = [14.21683569, 27.060114248];
|
let center = [14.21683569, 27.060114248];
|
||||||
let zoom = 2.661;
|
let zoom = 2.661;
|
||||||
|
let restoredFromStorage = false;
|
||||||
|
|
||||||
// Try to restore from localStorage
|
// Try to restore from localStorage
|
||||||
try {
|
try {
|
||||||
@@ -79,6 +83,7 @@ export default class MapComponent extends Component {
|
|||||||
) {
|
) {
|
||||||
center = parsed.center;
|
center = parsed.center;
|
||||||
zoom = parsed.zoom;
|
zoom = parsed.zoom;
|
||||||
|
restoredFromStorage = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -96,10 +101,13 @@ export default class MapComponent extends Component {
|
|||||||
layers: [openfreemap, bookmarkLayer],
|
layers: [openfreemap, bookmarkLayer],
|
||||||
view: view,
|
view: view,
|
||||||
controls: defaultControls({
|
controls: defaultControls({
|
||||||
zoom: false,
|
zoom: true,
|
||||||
rotate: true,
|
rotate: true,
|
||||||
attribution: true,
|
attribution: true,
|
||||||
}),
|
}),
|
||||||
|
interactions: defaultInteractions({
|
||||||
|
dragPan: false, // Disable default DragPan to add a custom one
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
apply(this.mapInstance, 'https://tiles.openfreemap.org/styles/liberty');
|
apply(this.mapInstance, 'https://tiles.openfreemap.org/styles/liberty');
|
||||||
@@ -237,6 +245,7 @@ export default class MapComponent extends Component {
|
|||||||
const coordinates = geolocation.getPosition();
|
const coordinates = geolocation.getPosition();
|
||||||
const accuracyGeometry = geolocation.getAccuracyGeometry();
|
const accuracyGeometry = geolocation.getAccuracyGeometry();
|
||||||
const accuracy = geolocation.getAccuracy();
|
const accuracy = geolocation.getAccuracy();
|
||||||
|
console.debug('Geolocation change:', { coordinates, accuracy });
|
||||||
|
|
||||||
if (!coordinates) return;
|
if (!coordinates) return;
|
||||||
|
|
||||||
@@ -301,7 +310,8 @@ export default class MapComponent extends Component {
|
|||||||
this.mapInstance.getView().animate(viewOptions);
|
this.mapInstance.getView().animate(viewOptions);
|
||||||
};
|
};
|
||||||
|
|
||||||
locateBtn.addEventListener('click', () => {
|
const startLocating = () => {
|
||||||
|
console.debug('Getting current geolocation...')
|
||||||
// 1. Clear any previous session
|
// 1. Clear any previous session
|
||||||
stopLocating();
|
stopLocating();
|
||||||
|
|
||||||
@@ -325,7 +335,9 @@ export default class MapComponent extends Component {
|
|||||||
locateTimeout = setTimeout(() => {
|
locateTimeout = setTimeout(() => {
|
||||||
stopLocating();
|
stopLocating();
|
||||||
}, 10000);
|
}, 10000);
|
||||||
});
|
};
|
||||||
|
|
||||||
|
locateBtn.addEventListener('click', startLocating);
|
||||||
|
|
||||||
const locateControl = new Control({
|
const locateControl = new Control({
|
||||||
element: locateElement,
|
element: locateElement,
|
||||||
@@ -334,6 +346,11 @@ export default class MapComponent extends Component {
|
|||||||
this.mapInstance.addLayer(geolocationLayer);
|
this.mapInstance.addLayer(geolocationLayer);
|
||||||
this.mapInstance.addControl(locateControl);
|
this.mapInstance.addControl(locateControl);
|
||||||
|
|
||||||
|
// Auto-locate on first visit (if not restored from storage and on home page)
|
||||||
|
if (!restoredFromStorage && this.router.currentRouteName === 'index') {
|
||||||
|
startLocating();
|
||||||
|
}
|
||||||
|
|
||||||
this.mapInstance.on('singleclick', this.handleMapClick);
|
this.mapInstance.on('singleclick', this.handleMapClick);
|
||||||
|
|
||||||
// Load places when map moves
|
// Load places when map moves
|
||||||
@@ -353,6 +370,38 @@ export default class MapComponent extends Component {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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)
|
// Track the selected place from the UI Service (Router -> Map)
|
||||||
updateSelectedPin = modifier(() => {
|
updateSelectedPin = modifier(() => {
|
||||||
const selected = this.mapUi.selectedPlace;
|
const selected = this.mapUi.selectedPlace;
|
||||||
@@ -695,6 +744,13 @@ export default class MapComponent extends Component {
|
|||||||
return;
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
const coords = toLonLat(event.coordinate);
|
const coords = toLonLat(event.coordinate);
|
||||||
const [lon, lat] = coords;
|
const [lon, lat] = coords;
|
||||||
|
|
||||||
@@ -736,6 +792,7 @@ export default class MapComponent extends Component {
|
|||||||
<div
|
<div
|
||||||
class="map-container {{if @isSidebarOpen 'sidebar-open'}}"
|
class="map-container {{if @isSidebarOpen 'sidebar-open'}}"
|
||||||
{{this.setupMap}}
|
{{this.setupMap}}
|
||||||
|
{{this.updateInteractions}}
|
||||||
{{this.updateBookmarks}}
|
{{this.updateBookmarks}}
|
||||||
{{this.updateSelectedPin}}
|
{{this.updateSelectedPin}}
|
||||||
{{this.syncPulse}}
|
{{this.syncPulse}}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import Component from '@glimmer/component';
|
|||||||
import { fn } from '@ember/helper';
|
import { fn } from '@ember/helper';
|
||||||
import { on } from '@ember/modifier';
|
import { on } from '@ember/modifier';
|
||||||
import { humanizeOsmTag } from '../utils/format-text';
|
import { humanizeOsmTag } from '../utils/format-text';
|
||||||
|
import { getLocalizedName } from '../utils/osm';
|
||||||
import Icon from '../components/icon';
|
import Icon from '../components/icon';
|
||||||
import PlaceEditForm from './place-edit-form';
|
import PlaceEditForm from './place-edit-form';
|
||||||
|
|
||||||
@@ -22,8 +23,7 @@ export default class PlaceDetails extends Component {
|
|||||||
get name() {
|
get name() {
|
||||||
return (
|
return (
|
||||||
this.place.title ||
|
this.place.title ||
|
||||||
this.tags.name ||
|
getLocalizedName(this.tags) ||
|
||||||
this.tags['name:en'] ||
|
|
||||||
'Unnamed Place'
|
'Unnamed Place'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -140,6 +140,8 @@ export default class PlaceDetails extends Component {
|
|||||||
}
|
}
|
||||||
|
|
||||||
get gmapsUrl() {
|
get gmapsUrl() {
|
||||||
|
const id = this.place.gmapsId || this.place.osmId;
|
||||||
|
if (!id) return null;
|
||||||
return `https://www.google.com/maps/search/?api=1&query=${this.name}&query=${this.place.lat},${this.place.lon}`;
|
return `https://www.google.com/maps/search/?api=1&query=${this.name}&query=${this.place.lat},${this.place.lon}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -259,7 +261,7 @@ export default class PlaceDetails extends Component {
|
|||||||
|
|
||||||
{{#if this.osmUrl}}
|
{{#if this.osmUrl}}
|
||||||
<p class="content-with-icon">
|
<p class="content-with-icon">
|
||||||
<Icon @name="map" @title="OSM ID" />
|
<Icon @name="map" />
|
||||||
<span>
|
<span>
|
||||||
<a href={{this.osmUrl}} target="_blank" rel="noopener noreferrer">
|
<a href={{this.osmUrl}} target="_blank" rel="noopener noreferrer">
|
||||||
OpenStreetMap
|
OpenStreetMap
|
||||||
@@ -268,14 +270,16 @@ export default class PlaceDetails extends Component {
|
|||||||
</p>
|
</p>
|
||||||
{{/if}}
|
{{/if}}
|
||||||
|
|
||||||
|
{{#if this.gmapsUrl}}
|
||||||
<p class="content-with-icon">
|
<p class="content-with-icon">
|
||||||
<Icon @name="map" @title="OSM ID" />
|
<Icon @name="map" />
|
||||||
<span>
|
<span>
|
||||||
<a href={{this.gmapsUrl}} target="_blank" rel="noopener noreferrer">
|
<a href={{this.gmapsUrl}} target="_blank" rel="noopener noreferrer">
|
||||||
Google Maps
|
Google Maps
|
||||||
</a>
|
</a>
|
||||||
</span>
|
</span>
|
||||||
</p>
|
</p>
|
||||||
|
{{/if}}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -13,6 +13,13 @@ export default class PlaceEditForm extends Component {
|
|||||||
this.description = this.args.place?.description || '';
|
this.description = this.args.place?.description || '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get shouldAutofocus() {
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
return window.innerWidth > 768;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
@action
|
@action
|
||||||
handleSubmit(event) {
|
handleSubmit(event) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
@@ -45,7 +52,7 @@ export default class PlaceEditForm extends Component {
|
|||||||
{{on "input" this.updateTitle}}
|
{{on "input" this.updateTitle}}
|
||||||
class="form-control"
|
class="form-control"
|
||||||
placeholder="Name of the place"
|
placeholder="Name of the place"
|
||||||
autofocus
|
autofocus={{this.shouldAutofocus}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import or from 'ember-truth-helpers/helpers/or';
|
|||||||
import PlaceDetails from './place-details';
|
import PlaceDetails from './place-details';
|
||||||
import Icon from './icon';
|
import Icon from './icon';
|
||||||
import humanizeOsmTag from '../helpers/humanize-osm-tag';
|
import humanizeOsmTag from '../helpers/humanize-osm-tag';
|
||||||
|
import { getLocalizedName } from '../utils/osm';
|
||||||
|
|
||||||
export default class PlacesSidebar extends Component {
|
export default class PlacesSidebar extends Component {
|
||||||
@service storage;
|
@service storage;
|
||||||
@@ -85,8 +86,7 @@ export default class PlacesSidebar extends Component {
|
|||||||
} else {
|
} else {
|
||||||
// It's a fresh POI -> Save it
|
// It's a fresh POI -> Save it
|
||||||
const placeData = {
|
const placeData = {
|
||||||
title:
|
title: getLocalizedName(place.osmTags, 'Untitled Place'),
|
||||||
place.osmTags.name || place.osmTags['name:en'] || 'Untitled Place',
|
|
||||||
lat: place.lat,
|
lat: place.lat,
|
||||||
lon: place.lon,
|
lon: place.lon,
|
||||||
tags: [],
|
tags: [],
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { service } from '@ember/service';
|
|||||||
import { action } from '@ember/object';
|
import { action } from '@ember/object';
|
||||||
import Icon from '#components/icon';
|
import Icon from '#components/icon';
|
||||||
import eq from 'ember-truth-helpers/helpers/eq';
|
import eq from 'ember-truth-helpers/helpers/eq';
|
||||||
|
import not from 'ember-truth-helpers/helpers/not';
|
||||||
|
|
||||||
export default class SettingsPane extends Component {
|
export default class SettingsPane extends Component {
|
||||||
@service settings;
|
@service settings;
|
||||||
@@ -13,6 +14,11 @@ export default class SettingsPane extends Component {
|
|||||||
this.settings.updateOverpassApi(event.target.value);
|
this.settings.updateOverpassApi(event.target.value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
toggleKinetic(event) {
|
||||||
|
this.settings.updateMapKinetic(event.target.value === 'true');
|
||||||
|
}
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="sidebar settings-pane">
|
<div class="sidebar settings-pane">
|
||||||
<div class="sidebar-header">
|
<div class="sidebar-header">
|
||||||
@@ -25,6 +31,27 @@ export default class SettingsPane extends Component {
|
|||||||
<div class="sidebar-content">
|
<div class="sidebar-content">
|
||||||
<section class="settings-section">
|
<section class="settings-section">
|
||||||
<h3>Settings</h3>
|
<h3>Settings</h3>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="map-kinetic">Map Inertia (Kinetic Panning)</label>
|
||||||
|
<select
|
||||||
|
id="map-kinetic"
|
||||||
|
class="form-control"
|
||||||
|
{{on "change" this.toggleKinetic}}
|
||||||
|
>
|
||||||
|
<option
|
||||||
|
value="true"
|
||||||
|
selected={{if this.settings.mapKinetic "selected"}}
|
||||||
|
>
|
||||||
|
On
|
||||||
|
</option>
|
||||||
|
<option
|
||||||
|
value="false"
|
||||||
|
selected={{unless this.settings.mapKinetic "selected"}}
|
||||||
|
>
|
||||||
|
Off
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="overpass-api">Overpass API Provider</label>
|
<label for="overpass-api">Overpass API Provider</label>
|
||||||
<select
|
<select
|
||||||
|
|||||||
@@ -56,6 +56,8 @@ export default class PlaceRoute extends Route {
|
|||||||
deactivate() {
|
deactivate() {
|
||||||
// Clear the pin when leaving the route
|
// Clear the pin when leaving the route
|
||||||
this.mapUi.clearSelection();
|
this.mapUi.clearSelection();
|
||||||
|
// Reset the "return to search" flag so it doesn't persist to subsequent navigations
|
||||||
|
this.mapUi.returnToSearch = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
async loadOsmPlace(id, type = null) {
|
async loadOsmPlace(id, type = null) {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ export default class MapUiService extends Service {
|
|||||||
@tracked isSearching = false;
|
@tracked isSearching = false;
|
||||||
@tracked isCreating = false;
|
@tracked isCreating = false;
|
||||||
@tracked creationCoordinates = null;
|
@tracked creationCoordinates = null;
|
||||||
|
@tracked returnToSearch = false;
|
||||||
|
|
||||||
selectPlace(place) {
|
selectPlace(place) {
|
||||||
this.selectedPlace = place;
|
this.selectedPlace = place;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import Service, { service } from '@ember/service';
|
import Service, { service } from '@ember/service';
|
||||||
|
import { getLocalizedName } from '../utils/osm';
|
||||||
|
|
||||||
export default class OsmService extends Service {
|
export default class OsmService extends Service {
|
||||||
@service settings;
|
@service settings;
|
||||||
@@ -61,7 +62,7 @@ out center;
|
|||||||
|
|
||||||
normalizePoi(poi) {
|
normalizePoi(poi) {
|
||||||
return {
|
return {
|
||||||
title: poi.tags?.name || poi.tags?.['name:en'] || 'Untitled Place',
|
title: getLocalizedName(poi.tags),
|
||||||
lat: poi.lat || poi.center?.lat,
|
lat: poi.lat || poi.center?.lat,
|
||||||
lon: poi.lon || poi.center?.lon,
|
lon: poi.lon || poi.center?.lon,
|
||||||
url: poi.tags?.website,
|
url: poi.tags?.website,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { tracked } from '@glimmer/tracking';
|
|||||||
|
|
||||||
export default class SettingsService extends Service {
|
export default class SettingsService extends Service {
|
||||||
@tracked overpassApi = 'https://overpass.bke.ro/api/interpreter';
|
@tracked overpassApi = 'https://overpass.bke.ro/api/interpreter';
|
||||||
|
@tracked mapKinetic = true;
|
||||||
|
|
||||||
overpassApis = [
|
overpassApis = [
|
||||||
{ name: 'bke.ro', url: 'https://overpass.bke.ro/api/interpreter' },
|
{ name: 'bke.ro', url: 'https://overpass.bke.ro/api/interpreter' },
|
||||||
@@ -19,14 +20,25 @@ export default class SettingsService extends Service {
|
|||||||
}
|
}
|
||||||
|
|
||||||
loadSettings() {
|
loadSettings() {
|
||||||
const savedApi = localStorage.getItem('marco-overpass-api');
|
const savedApi = localStorage.getItem('marco:overpass-api');
|
||||||
if (savedApi) {
|
if (savedApi) {
|
||||||
this.overpassApi = savedApi;
|
this.overpassApi = savedApi;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const savedKinetic = localStorage.getItem('marco:map-kinetic');
|
||||||
|
if (savedKinetic !== null) {
|
||||||
|
this.mapKinetic = savedKinetic === 'true';
|
||||||
|
}
|
||||||
|
// Default is true (initialized in class field)
|
||||||
}
|
}
|
||||||
|
|
||||||
updateOverpassApi(url) {
|
updateOverpassApi(url) {
|
||||||
this.overpassApi = url;
|
this.overpassApi = url;
|
||||||
localStorage.setItem('marco-overpass-api', url);
|
localStorage.setItem('marco:overpass-api', url);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateMapKinetic(enabled) {
|
||||||
|
this.mapKinetic = enabled;
|
||||||
|
localStorage.setItem('marco:map-kinetic', String(enabled));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,11 @@ body {
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
overscroll-behavior: none; /* Prevent pull-to-refresh on mobile */
|
overscroll-behavior: none; /* Prevent pull-to-refresh on mobile */
|
||||||
-webkit-text-size-adjust: 100%;
|
-webkit-text-size-adjust: 100%;
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
@@ -69,7 +74,15 @@ body {
|
|||||||
pointer-events: auto; /* Re-enable clicks for buttons */
|
pointer-events: auto; /* Re-enable clicks for buttons */
|
||||||
}
|
}
|
||||||
|
|
||||||
.icon-btn {
|
.btn-press {
|
||||||
|
transition: transform 0.1s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-press:active {
|
||||||
|
transform: scale(0.95);
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-btn {
|
||||||
background: white;
|
background: white;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
@@ -80,11 +93,6 @@ body {
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
box-shadow: 0 2px 5px rgb(0 0 0 / 20%);
|
box-shadow: 0 2px 5px rgb(0 0 0 / 20%);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: transform 0.1s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.icon-btn:active {
|
|
||||||
transform: scale(0.95);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.user-btn {
|
.user-btn {
|
||||||
@@ -539,22 +547,40 @@ body {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Locate Control */
|
/* Zoom Control - Moved to bottom right above attribution */
|
||||||
|
.ol-zoom {
|
||||||
|
top: auto !important;
|
||||||
|
left: auto !important;
|
||||||
|
bottom: 2.5em;
|
||||||
|
right: 0.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ol-touch .ol-zoom {
|
||||||
|
bottom: 3.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Locate Control - Above Zoom */
|
||||||
.ol-control.ol-locate {
|
.ol-control.ol-locate {
|
||||||
inset: auto 0.5em 2.5em auto;
|
top: auto !important;
|
||||||
|
left: auto !important;
|
||||||
|
bottom: 6.5em;
|
||||||
|
right: 0.5em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ol-touch .ol-control.ol-locate {
|
.ol-touch .ol-control.ol-locate {
|
||||||
inset: auto 0.5em 3.5em auto;
|
bottom: 8.5em;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Rotate Control */
|
/* Rotate Control - Above Locate */
|
||||||
.ol-rotate {
|
.ol-rotate {
|
||||||
inset: auto 0.5em 5em auto;
|
top: auto !important;
|
||||||
|
left: auto !important;
|
||||||
|
bottom: 9em;
|
||||||
|
right: 0.5em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ol-touch .ol-rotate {
|
.ol-touch .ol-rotate {
|
||||||
inset: auto 0.5em 6em auto;
|
bottom: 11.5em;
|
||||||
}
|
}
|
||||||
|
|
||||||
span.icon {
|
span.icon {
|
||||||
|
|||||||
@@ -77,11 +77,11 @@ export default class PlaceTemplate extends Component {
|
|||||||
navigateBack(place) {
|
navigateBack(place) {
|
||||||
// The sidebar calls this with null when "Back" is clicked.
|
// The sidebar calls this with null when "Back" is clicked.
|
||||||
if (place === null) {
|
if (place === null) {
|
||||||
// If we have history, go back (preserves search state)
|
// If we came from search results, go back in history
|
||||||
if (window.history.length > 1) {
|
if (this.mapUi.returnToSearch) {
|
||||||
window.history.back();
|
window.history.back();
|
||||||
} else {
|
} else {
|
||||||
// Fallback if opened directly
|
// Otherwise just close the sidebar (return to map index)
|
||||||
this.router.transitionTo('index');
|
this.router.transitionTo('index');
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -5,10 +5,12 @@ import { action } from '@ember/object';
|
|||||||
|
|
||||||
export default class SearchTemplate extends Component {
|
export default class SearchTemplate extends Component {
|
||||||
@service router;
|
@service router;
|
||||||
|
@service mapUi;
|
||||||
|
|
||||||
@action
|
@action
|
||||||
selectPlace(place) {
|
selectPlace(place) {
|
||||||
if (place) {
|
if (place) {
|
||||||
|
this.mapUi.returnToSearch = true;
|
||||||
this.router.transitionTo('place', place);
|
this.router.transitionTo('place', place);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
32
app/utils/osm.js
Normal file
32
app/utils/osm.js
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "marco",
|
"name": "marco",
|
||||||
"version": "1.10.0",
|
"version": "1.11.4",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "Unhosted maps app",
|
"description": "Unhosted maps app",
|
||||||
"repository": {
|
"repository": {
|
||||||
@@ -34,6 +34,7 @@
|
|||||||
"lint:js:fix": "eslint . --fix",
|
"lint:js:fix": "eslint . --fix",
|
||||||
"start": "vite",
|
"start": "vite",
|
||||||
"test": "vite build --mode development && testem ci --port 0",
|
"test": "vite build --mode development && testem ci --port 0",
|
||||||
|
"preversion": "pnpm test",
|
||||||
"version": "pnpm build && git add release/"
|
"version": "pnpm build && git add release/"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
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-G8wPYi_P.css
Normal file
1
release/assets/main-G8wPYi_P.css
Normal file
File diff suppressed because one or more lines are too long
2
release/assets/main-ji2SNMnp.js
Normal file
2
release/assets/main-ji2SNMnp.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
@@ -26,8 +26,8 @@
|
|||||||
<meta name="msapplication-TileColor" content="#F6E9A6">
|
<meta name="msapplication-TileColor" content="#F6E9A6">
|
||||||
<meta name="msapplication-TileImage" content="/icons/icon-144.png">
|
<meta name="msapplication-TileImage" content="/icons/icon-144.png">
|
||||||
|
|
||||||
<script type="module" crossorigin src="/assets/main-BZ6SjjAk.js"></script>
|
<script type="module" crossorigin src="/assets/main-ji2SNMnp.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/main-D53xPL_H.css">
|
<link rel="stylesheet" crossorigin href="/assets/main-G8wPYi_P.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
106
tests/acceptance/navigation-test.js
Normal file
106
tests/acceptance/navigation-test.js
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
import { module, test } from 'qunit';
|
||||||
|
import { visit, currentURL, click, settled } from '@ember/test-helpers';
|
||||||
|
import { setupApplicationTest } from 'marco/tests/helpers';
|
||||||
|
import Service from '@ember/service';
|
||||||
|
import sinon from 'sinon';
|
||||||
|
|
||||||
|
class MockOsmService extends Service {
|
||||||
|
async getNearbyPois() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
osmId: '123',
|
||||||
|
lat: 1,
|
||||||
|
lon: 1,
|
||||||
|
osmTags: { name: 'Test Place', amenity: 'cafe' },
|
||||||
|
osmType: 'node',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
async getPoiById() {
|
||||||
|
return {
|
||||||
|
osmId: '123',
|
||||||
|
lat: 1,
|
||||||
|
lon: 1,
|
||||||
|
osmTags: { name: 'Test Place', amenity: 'cafe' },
|
||||||
|
osmType: 'node',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class MockStorageService extends Service {
|
||||||
|
savedPlaces = [];
|
||||||
|
findPlaceById() {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
loadPlacesInBounds() {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
get placesInView() {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
rs = {
|
||||||
|
on: () => {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module('Acceptance | navigation', function (hooks) {
|
||||||
|
setupApplicationTest(hooks);
|
||||||
|
|
||||||
|
hooks.beforeEach(function () {
|
||||||
|
this.owner.register('service:osm', MockOsmService);
|
||||||
|
this.owner.register('service:storage', MockStorageService);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('navigating from search results to place and back uses history', async function (assert) {
|
||||||
|
const mapUi = this.owner.lookup('service:map-ui');
|
||||||
|
const backStub = sinon.stub(window.history, 'back');
|
||||||
|
|
||||||
|
try {
|
||||||
|
await visit('/search?lat=1&lon=1');
|
||||||
|
assert.strictEqual(currentURL(), '/search?lat=1&lon=1');
|
||||||
|
|
||||||
|
await click('.place-item');
|
||||||
|
assert.ok(currentURL().includes('/place/'), 'Navigated to place');
|
||||||
|
assert.true(mapUi.returnToSearch, 'Flag returnToSearch is set');
|
||||||
|
|
||||||
|
// Click the back button in the sidebar
|
||||||
|
await click('.back-btn');
|
||||||
|
|
||||||
|
assert.true(backStub.calledOnce, 'window.history.back() was called');
|
||||||
|
} finally {
|
||||||
|
backStub.restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('closing the sidebar resets the returnToSearch flag', async function (assert) {
|
||||||
|
const mapUi = this.owner.lookup('service:map-ui');
|
||||||
|
|
||||||
|
await visit('/search?lat=1&lon=1');
|
||||||
|
await click('.place-item'); // Sets returnToSearch = true
|
||||||
|
|
||||||
|
assert.true(mapUi.returnToSearch, 'Flag is set upon entering place');
|
||||||
|
|
||||||
|
// Click the Close (X) button
|
||||||
|
await click('.close-btn');
|
||||||
|
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user