9 Commits

Author SHA1 Message Date
9f48d7b264 1.11.2 2026-02-08 17:01:01 +04:00
bbd3bf47c6 Merge pull request 'Fix back button behavior' (#14) from bugfix/back_button into master
Reviewed-on: #14
2026-02-08 13:00:07 +00:00
59e3d91071 Fix back button behavior
fixes #12
2026-02-08 16:59:53 +04:00
348b721876 1.11.1 2026-01-27 15:05:08 +07:00
3d982a6a7c More kinetic panning optimizations 2026-01-27 15:04:25 +07:00
0af9d9f16d 1.11.0 2026-01-27 14:24:52 +07:00
a0f132ec64 Disable kinetic panning on mobile by default, add setting for it 2026-01-27 14:23:43 +07:00
925f26ae5d Update status doc 2026-01-27 14:08:27 +07:00
58bb8831f3 Prevent autofocus on mobile
Makes it difficult to fine-tune the location first
2026-01-27 14:06:26 +07:00
14 changed files with 224 additions and 17 deletions

View File

@@ -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.
- **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
- **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 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/services/osm.js`: Caching logic.
- `app/routes/search.js`: Search heuristics.
- `app/components/place-details.gjs`: Formatting logic.
- `app/components/map.gjs`
- `app/components/place-edit-form.gjs`
- `app/templates/place/new.gjs`
## 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.
3. **Performance:** Monitor performance with large datasets (thousands of bookmarks).

View File

@@ -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';
@@ -21,6 +23,7 @@ export default class MapComponent extends Component {
@service storage;
@service mapUi;
@service router;
@service settings;
mapInstance;
bookmarkSource;
@@ -100,6 +103,9 @@ export default class MapComponent extends Component {
rotate: true,
attribution: true,
}),
interactions: defaultInteractions({
dragPan: false, // Disable default DragPan to add a custom one
}),
});
apply(this.mapInstance, 'https://tiles.openfreemap.org/styles/liberty');
@@ -353,6 +359,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)
updateSelectedPin = modifier(() => {
const selected = this.mapUi.selectedPlace;
@@ -736,6 +774,7 @@ export default class MapComponent extends Component {
<div
class="map-container {{if @isSidebarOpen 'sidebar-open'}}"
{{this.setupMap}}
{{this.updateInteractions}}
{{this.updateBookmarks}}
{{this.updateSelectedPin}}
{{this.syncPulse}}

View File

@@ -13,6 +13,13 @@ export default class PlaceEditForm extends Component {
this.description = this.args.place?.description || '';
}
get shouldAutofocus() {
if (typeof window !== 'undefined') {
return window.innerWidth > 768;
}
return false;
}
@action
handleSubmit(event) {
event.preventDefault();
@@ -45,7 +52,7 @@ export default class PlaceEditForm extends Component {
{{on "input" this.updateTitle}}
class="form-control"
placeholder="Name of the place"
autofocus
autofocus={{this.shouldAutofocus}}
/>
</div>
<div class="form-group">

View File

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

View File

@@ -56,6 +56,8 @@ export default class PlaceRoute extends Route {
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) {

View File

@@ -6,6 +6,7 @@ export default class MapUiService extends Service {
@tracked isSearching = false;
@tracked isCreating = false;
@tracked creationCoordinates = null;
@tracked returnToSearch = false;
selectPlace(place) {
this.selectedPlace = place;

View File

@@ -3,6 +3,7 @@ import { tracked } from '@glimmer/tracking';
export default class SettingsService extends Service {
@tracked overpassApi = 'https://overpass.bke.ro/api/interpreter';
@tracked mapKinetic = true;
overpassApis = [
{ name: 'bke.ro', url: 'https://overpass.bke.ro/api/interpreter' },
@@ -19,14 +20,25 @@ 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;
}
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));
}
}

View File

@@ -77,11 +77,11 @@ export default class PlaceTemplate extends Component {
navigateBack(place) {
// The sidebar calls this with null when "Back" is clicked.
if (place === null) {
// If we have history, go back (preserves search state)
if (window.history.length > 1) {
// If we came from search results, go back in history
if (this.mapUi.returnToSearch) {
window.history.back();
} else {
// Fallback if opened directly
// Otherwise just close the sidebar (return to map index)
this.router.transitionTo('index');
}
} else {

View File

@@ -5,10 +5,12 @@ 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);
}
}

View File

@@ -1,6 +1,6 @@
{
"name": "marco",
"version": "1.10.1",
"version": "1.11.2",
"private": true,
"description": "Unhosted maps app",
"repository": {

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -26,7 +26,7 @@
<meta name="msapplication-TileColor" content="#F6E9A6">
<meta name="msapplication-TileImage" content="/icons/icon-144.png">
<script type="module" crossorigin src="/assets/main-Dep3TjPE.js"></script>
<script type="module" crossorigin src="/assets/main-CYFdUlXN.js"></script>
<link rel="stylesheet" crossorigin href="/assets/main-D53xPL_H.css">
</head>
<body>

View File

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