17 Commits

Author SHA1 Message Date
20f63065ad 1.11.4 2026-02-10 19:21:34 +04:00
39a7ec3595 Improve code based on linting 2026-02-10 19:20:38 +04:00
32dfa3a30f Merge pull request 'Prefer place name in UA/browser language' (#19) from feature/16-place_names into master
Reviewed-on: #19
2026-02-10 15:20:23 +00:00
64ccc694d3 Prefer place name in UA/browser language
closes #16
2026-02-10 19:19:36 +04:00
87e2380ef6 1.11.3 2026-02-10 18:53:21 +04:00
66c31b19f1 Merge pull request 'UI improvements' (#18) from feature/location_improvements into master
Reviewed-on: #18
2026-02-10 14:52:22 +00:00
55aecbd699 Apply same button-press effect to both header buttons 2026-02-10 18:51:26 +04:00
ccaa56b78f Remove remaining default tap highlights on mobiles 2026-02-10 18:44:41 +04:00
d30375707a Prevent map search when zoomed out too much
It's usually an accidental click, and if not, the search radius/pulse
wouldn't be clearly visible.
2026-02-10 18:33:44 +04:00
53300b92f5 Re-add zoom controls 2026-02-10 17:47:03 +04:00
c37f794eea Auto-locate user on first app launch
closes #17
2026-02-10 17:18:59 +04:00
4bc92bb7cc Run tests before versioning 2026-02-08 17:01:56 +04:00
9f48d7b264 1.11.2 2026-02-08 17:01:01 +04:00
bbd3bf47c6 Merge pull request 'Fix back button behavior' (#14) from bugfix/back_button into master
Reviewed-on: #14
2026-02-08 13:00:07 +00:00
59e3d91071 Fix back button behavior
fixes #12
2026-02-08 16:59:53 +04:00
348b721876 1.11.1 2026-01-27 15:05:08 +07:00
3d982a6a7c More kinetic panning optimizations 2026-01-27 15:04:25 +07:00
20 changed files with 233 additions and 39 deletions

View File

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

View File

@@ -68,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 {
@@ -82,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) {
@@ -99,7 +101,7 @@ 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,
}), }),
@@ -243,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;
@@ -307,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();
@@ -331,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,
@@ -340,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
@@ -374,6 +385,16 @@ export default class MapComponent extends Component {
? new Kinetic(-0.005, 0.05, 100) ? new Kinetic(-0.005, 0.05, 100)
: false; : 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( this.mapInstance.addInteraction(
new DragPan({ new DragPan({
kinetic: kinetic, kinetic: kinetic,
@@ -723,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;

View File

@@ -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'
); );
} }

View File

@@ -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: [],

View File

@@ -46,7 +46,7 @@ export default class SettingsPane extends Component {
</option> </option>
<option <option
value="false" value="false"
selected={{if (not this.settings.mapKinetic) "selected"}} selected={{unless this.settings.mapKinetic "selected"}}
> >
Off Off
</option> </option>

View File

@@ -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) {

View File

@@ -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;

View File

@@ -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,

View File

@@ -28,13 +28,8 @@ export default class SettingsService extends Service {
const savedKinetic = localStorage.getItem('marco:map-kinetic'); const savedKinetic = localStorage.getItem('marco:map-kinetic');
if (savedKinetic !== null) { if (savedKinetic !== null) {
this.mapKinetic = savedKinetic === 'true'; this.mapKinetic = savedKinetic === 'true';
} else {
// Default: disabled on small screens (mobile), enabled on desktop
// We check for typical mobile width (<= 768px)
if (typeof window !== 'undefined') {
this.mapKinetic = window.innerWidth > 768;
}
} }
// Default is true (initialized in class field)
} }
updateOverpassApi(url) { updateOverpassApi(url) {

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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
View 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;
}

View File

@@ -1,6 +1,6 @@
{ {
"name": "marco", "name": "marco",
"version": "1.11.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

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,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-y8e9Z0x2.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>

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');
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();
}
});
});