21 Commits

Author SHA1 Message Date
e7b3b72e2f 1.9.0 2026-01-27 11:22:37 +07:00
399ad1822d Humanize place type properly, refactor for other tags 2026-01-27 11:21:51 +07:00
104a742543 Use dark grey for all text, change theme color 2026-01-27 11:00:06 +07:00
a8dc4c81e4 Implement simple query cache for Overpass/OSM search
So when we return to the search route, we don't have to refetch
2026-01-27 09:50:41 +07:00
156280950f Refactor search results with dedicated route 2026-01-27 09:50:26 +07:00
41d61be42e 1.8.10 2026-01-27 08:55:23 +07:00
06b47d96a7 Fix search results scrolling behavior 2026-01-27 08:54:42 +07:00
e8af959be6 Improve search results layout/styling 2026-01-27 08:54:38 +07:00
254e177cbf Update README 2026-01-26 19:55:18 +07:00
47fbc8e7cf Use published places module 2026-01-26 18:12:29 +07:00
4ad0df22e2 1.8.9 2026-01-26 17:53:09 +07:00
0decb4cf1b Optimize animations on iOS 2026-01-26 17:52:41 +07:00
2193f935cc Change default center and zoom to show the world on desktop 2026-01-26 17:52:14 +07:00
b2b03c0a38 1.8.8 2026-01-26 17:20:49 +07:00
0be02c5b20 Update status doc 2026-01-26 17:06:39 +07:00
653e44348c Fix auto-zoom when focussing form field on iOS 2026-01-26 17:01:29 +07:00
8fdc697a17 1.8.7 2026-01-26 16:46:34 +07:00
d9b2a17b91 Noto serif or no serif 2026-01-26 16:46:07 +07:00
85255318ba 1.8.6 2026-01-26 16:32:43 +07:00
713d9d53e6 Styling optimizations 2026-01-26 16:32:16 +07:00
e0ea0ca988 Prevent mobile Safari from resizing text 2026-01-26 16:23:46 +07:00
30 changed files with 329 additions and 203 deletions

View File

@@ -1,6 +1,6 @@
# Project Status: Marco # Project Status: Marco
**Last Updated:** Sat Jan 24 2026 **Last Updated:** Mon Jan 26 2026
## Project Context ## Project Context
@@ -21,9 +21,14 @@ We are building **Marco**, a decentralized maps application using **Ember.js** (
- **Visuals:** Increased bookmark marker size (Radius 9px) and added a subtle drop shadow. - **Visuals:** Increased bookmark marker size (Radius 9px) and added a subtle drop shadow.
- **Feedback:** Implemented a "pulse" animation (via OpenLayers Overlay) at the click location to visualize the search radius (30m/50m). - **Feedback:** Implemented a "pulse" animation (via OpenLayers Overlay) at the click location to visualize the search radius (30m/50m).
- **Mobile UX:** - **Mobile UX:**
- Disabled browser tap highlights (`-webkit-tap-highlight-color: transparent`) to prevent blue flashing on Android. - **Touch:** Disabled browser tap highlights (`-webkit-tap-highlight-color: transparent`) to prevent blue flashing on Android.
- Disabled "pull-to-refresh" (`overscroll-behavior: none`) on the body to prevent accidental reloads while keeping the sidebar scrollable (`contain`). - **Scroll:** Disabled "pull-to-refresh" (`overscroll-behavior: none`) on the body to prevent accidental reloads while keeping the sidebar scrollable (`contain`).
- **Auto-Pan:** On mobile screens, if a selected pin is obscured by the bottom sheet, the map automatically pans to center the pin in the visible top half of the screen. - **Auto-Pan:** On mobile screens, if a selected pin is obscured by the bottom sheet, the map automatically pans to center the pin in the visible top half of the screen.
- **Controls:** Fixed positioning of "Locate" and "Rotate" buttons on mobile by correcting CSS `inset` syntax.
- **iOS Polish:**
- Prevented input auto-zoom by ensuring `.form-control` font size is `1rem` (16px).
- Added `-webkit-text-size-adjust: 100%` to prevent text inflation on rotation.
- Set base `body` font size to `16px`.
- **Geolocation ("Locate Me"):** - **Geolocation ("Locate Me"):**
- Implemented a "Locate Me" button with robust tracking logic. - Implemented a "Locate Me" button with robust tracking logic.
- **Dynamic Zoom:** Automatically zooms to a level where the accuracy circle covers ~10% of the map (fallback logic handles missing accuracy data). - **Dynamic Zoom:** Automatically zooms to a level where the accuracy circle covers ~10% of the map (fallback logic handles missing accuracy data).
@@ -44,7 +49,7 @@ We are building **Marco**, a decentralized maps application using **Ember.js** (
- configured with `maxAge: false` to ensure data freshness. - configured with `maxAge: false` to ensure data freshness.
- **Dependencies:** Uses `ulid` and `latlon-geohash` internally. - **Dependencies:** Uses `ulid` and `latlon-geohash` internally.
### 3. App Infrastructure ### 3. App Infrastructure & Build
- **Services:** - **Services:**
- `storage.js`: Initializes RemoteStorage, claims access, enables caching, and sets up the widget. Consumes the new `getPlaces` API. - `storage.js`: Initializes RemoteStorage, claims access, enables caching, and sets up the widget. Consumes the new `getPlaces` API.
@@ -68,6 +73,11 @@ We are building **Marco**, a decentralized maps application using **Ember.js** (
- **Geo Utils:** - **Geo Utils:**
- `app/utils/geo.js`: Haversine distance calculations. - `app/utils/geo.js`: Haversine distance calculations.
- `app/utils/geohash-coverage.js`: Logic to calculate required 4-char geohash prefixes for a given bounding box. - `app/utils/geohash-coverage.js`: Logic to calculate required 4-char geohash prefixes for a given bounding box.
- **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 & Data Optimization
@@ -91,16 +101,17 @@ We are building **Marco**, a decentralized maps application using **Ember.js** (
## Files Currently in Focus ## Files Currently in Focus
- `app/templates/application.gjs`: Core layout and "Outside Click" logic. - `app/styles/app.css`: Mobile CSS fixes (font sizes, control positioning).
- `app/components/settings-pane.gjs`: Settings UI. - `package.json`: New scripts and dependencies.
- `app/services/settings.js`: Settings persistence. - `README.md`: Updated documentation.
## Next Steps & Pending Tasks ## Next Steps & Pending Tasks
1. **Collections/Lists:** Implement ability to organize bookmarks into lists/collections. 1. **Mobile Polish:** Verify "Locate Me" animation on iOS Safari.
2. **Linting & Code Quality:** Fix remaining CSS errors, remove inline styles in `map.gjs`, and address unused variables/runloop usage. 2. **Collections/Lists:** Implement ability to organize bookmarks into lists/collections.
3. **Performance:** Monitor performance with large datasets (thousands of bookmarks). 3. **Linting & Code Quality:** Fix remaining CSS errors, remove inline styles in `map.gjs`, and address unused variables/runloop usage.
4. **Testing:** Add automated tests for the geohash coverage, retry logic, and new editing features. 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.
## Technical Constraints ## Technical Constraints

View File

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

View File

@@ -17,6 +17,7 @@ import navigation from 'feather-icons/dist/icons/navigation.svg?raw';
import phone from 'feather-icons/dist/icons/phone.svg?raw'; import phone from 'feather-icons/dist/icons/phone.svg?raw';
import server from 'feather-icons/dist/icons/server.svg?raw'; import server from 'feather-icons/dist/icons/server.svg?raw';
import settings from 'feather-icons/dist/icons/settings.svg?raw'; import settings from 'feather-icons/dist/icons/settings.svg?raw';
import target from 'feather-icons/dist/icons/target.svg?raw';
import user from 'feather-icons/dist/icons/user.svg?raw'; import user from 'feather-icons/dist/icons/user.svg?raw';
import x from 'feather-icons/dist/icons/x.svg?raw'; import x from 'feather-icons/dist/icons/x.svg?raw';
import zap from 'feather-icons/dist/icons/zap.svg?raw'; import zap from 'feather-icons/dist/icons/zap.svg?raw';
@@ -38,6 +39,7 @@ const ICONS = {
phone, phone,
server, server,
settings, settings,
target,
user, user,
x, x,
zap, zap,

View File

@@ -21,6 +21,7 @@ export default class MapComponent extends Component {
@service osm; @service osm;
@service storage; @service storage;
@service mapUi; @service mapUi;
@service router;
mapInstance; mapInstance;
bookmarkSource; bookmarkSource;
@@ -61,8 +62,8 @@ export default class MapComponent extends Component {
}); });
// Default view settings // Default view settings
let center = [99.05738, 7.55087]; let center = [14.21683569, 27.060114248];
let zoom = 13.0; let zoom = 2.661;
// Try to restore from localStorage // Try to restore from localStorage
try { try {
@@ -510,6 +511,17 @@ 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');
}
});
handleMapMove = async () => { handleMapMove = async () => {
if (!this.mapInstance) return; if (!this.mapInstance) return;
@@ -546,7 +558,6 @@ export default class MapComponent extends Component {
}); });
let clickedBookmark = null; let clickedBookmark = null;
let selectedFeatureName = null; let selectedFeatureName = null;
let selectedFeatureType = null;
if (features && features.length > 0) { if (features && features.length > 0) {
console.debug(`Found ${features.length} features in map layer:`); console.debug(`Found ${features.length} features in map layer:`);
@@ -561,7 +572,6 @@ export default class MapComponent extends Component {
const props = features[0].getProperties(); const props = features[0].getProperties();
if (props.name) { if (props.name) {
selectedFeatureName = props.name; selectedFeatureName = props.name;
selectedFeatureType = props.class || props.subclass;
} }
} }
@@ -573,9 +583,7 @@ export default class MapComponent extends Component {
'Clicked bookmark while sidebar open (switching):', 'Clicked bookmark while sidebar open (switching):',
clickedBookmark clickedBookmark
); );
if (this.args.onPlacesFound) { this.router.transitionTo('place', clickedBookmark);
this.args.onPlacesFound([], clickedBookmark);
}
return; return;
} }
@@ -589,9 +597,7 @@ export default class MapComponent extends Component {
// Normal behavior (sidebar is closed) // Normal behavior (sidebar is closed)
if (clickedBookmark) { if (clickedBookmark) {
console.log('Clicked bookmark:', clickedBookmark); console.log('Clicked bookmark:', clickedBookmark);
if (this.args.onPlacesFound) { this.router.transitionTo('place', clickedBookmark);
this.args.onPlacesFound([], clickedBookmark);
}
return; return;
} }
@@ -615,76 +621,21 @@ export default class MapComponent extends Component {
this.searchOverlayElement.style.width = `${diameterInPixels}px`; this.searchOverlayElement.style.width = `${diameterInPixels}px`;
this.searchOverlayElement.style.height = `${diameterInPixels}px`; this.searchOverlayElement.style.height = `${diameterInPixels}px`;
this.searchOverlay.setPosition(event.coordinate); this.searchOverlay.setPosition(event.coordinate);
this.searchOverlayElement.classList.add('active');
} }
// 2. Fetch authoritative data via Overpass // Start Search State
try { this.mapUi.startSearch();
let pois = await this.osm.getNearbyPois(lat, lon, searchRadius);
// Sort by distance from click // Transition to Search Route
pois = pois const queryParams = {
.map((p) => { lat: lat.toFixed(6),
// p is already normalized by service, so lat/lon are at top level lon: lon.toFixed(6),
return { };
...p, if (selectedFeatureName) {
_distance: getDistance(lat, lon, p.lat, p.lon), queryParams.q = selectedFeatureName;
};
})
.sort((a, b) => a._distance - b._distance);
let matchedPlace = null;
if (selectedFeatureName && pois.length > 0) {
// Heuristic:
// 1. Exact Name Match
matchedPlace = pois.find(
(p) =>
p.osmTags &&
(p.osmTags.name === selectedFeatureName ||
p.osmTags['name:en'] === selectedFeatureName)
);
// 2. If no exact match, look for VERY close (<=20m) and matching type
if (!matchedPlace) {
const topCandidate = pois[0];
if (topCandidate._distance <= 20) {
// Check type compatibility if available
// (visual tile 'class' is often 'cafe', osm tag is 'amenity'='cafe')
const pType =
topCandidate.osmTags.amenity ||
topCandidate.osmTags.shop ||
topCandidate.osmTags.tourism;
if (
selectedFeatureType &&
pType &&
(selectedFeatureType === pType ||
pType.includes(selectedFeatureType))
) {
console.log(
'Heuristic match found (distance + type):',
topCandidate
);
matchedPlace = topCandidate;
} else if (topCandidate._distance <= 10) {
// Even without type match, if it's super close (<=10m), it's likely the one.
console.log('Heuristic match found (proximity):', topCandidate);
matchedPlace = topCandidate;
}
}
}
}
if (this.args.onPlacesFound) {
this.args.onPlacesFound(pois, matchedPlace);
}
} catch (error) {
console.error('Failed to fetch POIs:', error);
} finally {
if (this.searchOverlayElement) {
this.searchOverlayElement.classList.remove('active');
}
} }
this.router.transitionTo('search', { queryParams });
}; };
<template> <template>
@@ -693,6 +644,7 @@ export default class MapComponent extends Component {
{{this.setupMap}} {{this.setupMap}}
{{this.updateBookmarks}} {{this.updateBookmarks}}
{{this.updateSelectedPin}} {{this.updateSelectedPin}}
{{this.syncPulse}}
></div> ></div>
</template> </template>
} }

View File

@@ -1,7 +1,7 @@
import Component from '@glimmer/component'; import Component from '@glimmer/component';
import { fn } from '@ember/helper'; import { fn } from '@ember/helper';
import { on } from '@ember/modifier'; import { on } from '@ember/modifier';
import capitalize from '../helpers/capitalize'; import { humanizeOsmTag } from '../utils/format-text';
import Icon from '../components/icon'; import Icon from '../components/icon';
import { tracked } from '@glimmer/tracking'; import { tracked } from '@glimmer/tracking';
@@ -76,14 +76,15 @@ export default class PlaceDetails extends Component {
} }
get type() { get type() {
return ( const rawType =
this.tags.amenity || this.tags.amenity ||
this.tags.shop || this.tags.shop ||
this.tags.tourism || this.tags.tourism ||
this.tags.leisure || this.tags.leisure ||
this.tags.historic || this.tags.historic ||
'Point of Interest' 'Point of Interest';
);
return humanizeOsmTag(rawType);
} }
get address() { get address() {
@@ -133,8 +134,7 @@ export default class PlaceDetails extends Component {
if (!this.tags.cuisine) return null; if (!this.tags.cuisine) return null;
return this.tags.cuisine return this.tags.cuisine
.split(';') .split(';')
.map((c) => capitalize.compute([c])) .map((c) => humanizeOsmTag(c))
.map((c) => c.replace('_', ' '))
.join(', '); .join(', ');
} }

View File

@@ -6,6 +6,7 @@ import { fn } from '@ember/helper';
import or from 'ember-truth-helpers/helpers/or'; import or from 'ember-truth-helpers/helpers/or';
import PlaceDetails from './place-details'; import PlaceDetails from './place-details';
import Icon from './icon'; import Icon from './icon';
import humanizeOsmTag from '../helpers/humanize-osm-tag';
export default class PlacesSidebar extends Component { export default class PlacesSidebar extends Component {
@service storage; @service storage;
@@ -23,13 +24,6 @@ export default class PlacesSidebar extends Component {
if (this.args.onSelect) { if (this.args.onSelect) {
this.args.onSelect(null); this.args.onSelect(null);
} }
// Fallback logic: if no list available, close sidebar
if (!this.args.places || this.args.places.length === 0) {
if (this.args.onClose) {
this.args.onClose();
}
}
} }
@action @action
@@ -122,7 +116,7 @@ export default class PlacesSidebar extends Component {
try { try {
const savedPlace = await this.storage.updatePlace(updatedPlace); const savedPlace = await this.storage.updatePlace(updatedPlace);
console.log('Place updated:', savedPlace.title); console.log('Place updated:', savedPlace.title);
// Notify parent to refresh map/lists // Notify parent to refresh map/lists
if (this.args.onBookmarkChange) { if (this.args.onBookmarkChange) {
this.args.onBookmarkChange(); this.args.onBookmarkChange();
@@ -148,7 +142,7 @@ export default class PlacesSidebar extends Component {
{{on "click" this.clearSelection}} {{on "click" this.clearSelection}}
><Icon @name="arrow-left" @size={{20}} @color="#333" /></button> ><Icon @name="arrow-left" @size={{20}} @color="#333" /></button>
{{else}} {{else}}
<h2>Nearby Places</h2> <h2><Icon @name="target" @size={{20}} @color="#ea4335" /> Nearby</h2>
{{/if}} {{/if}}
<button type="button" class="close-btn" {{on "click" @onClose}}><Icon <button type="button" class="close-btn" {{on "click" @onClose}}><Icon
@name="x" @name="x"
@@ -180,13 +174,13 @@ export default class PlacesSidebar extends Component {
place.osmTags.name:en place.osmTags.name:en
"Unnamed Place" "Unnamed Place"
}}</div> }}</div>
<div class="place-type">{{or <div class="place-type">{{humanizeOsmTag (or
place.osmTags.amenity place.osmTags.amenity
place.osmTags.shop place.osmTags.shop
place.osmTags.tourism place.osmTags.tourism
place.osmTags.leisure place.osmTags.leisure
place.osmTags.historic place.osmTags.historic
}}</div> )}}</div>
</button> </button>
</li> </li>
{{/each}} {{/each}}

View File

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

View File

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

View File

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

View File

@@ -49,6 +49,8 @@ export default class PlaceRoute extends Route {
if (model) { if (model) {
this.mapUi.selectPlace(model); this.mapUi.selectPlace(model);
} }
// Stop the pulse animation if it was running (e.g. redirected from search)
this.mapUi.stopSearch();
} }
deactivate() { deactivate() {

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

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

View File

@@ -3,6 +3,7 @@ import { tracked } from '@glimmer/tracking';
export default class MapUiService extends Service { export default class MapUiService extends Service {
@tracked selectedPlace = null; @tracked selectedPlace = null;
@tracked isSearching = false;
selectPlace(place) { selectPlace(place) {
this.selectedPlace = place; this.selectedPlace = place;
@@ -11,4 +12,12 @@ export default class MapUiService extends Service {
clearSelection() { clearSelection() {
this.selectedPlace = null; this.selectedPlace = null;
} }
startSearch() {
this.isSearching = true;
}
stopSearch() {
this.isSearching = false;
}
} }

View File

@@ -4,8 +4,18 @@ export default class OsmService extends Service {
@service settings; @service settings;
controller = null; controller = null;
cachedResults = null;
lastQueryKey = null;
async getNearbyPois(lat, lon, radius = 50) { async getNearbyPois(lat, lon, radius = 50) {
const queryKey = `${lat},${lon},${radius}`;
// Return cached results if the query is identical to the last one
if (this.lastQueryKey === queryKey && this.cachedResults) {
console.log('Returning cached Overpass results for:', queryKey);
return this.cachedResults;
}
// Cancel previous request if it exists // Cancel previous request if it exists
if (this.controller) { if (this.controller) {
this.controller.abort(); this.controller.abort();
@@ -33,7 +43,13 @@ out center;
const data = await res.json(); const data = await res.json();
// Normalize data // Normalize data
return data.elements.map(this.normalizePoi); const results = data.elements.map(this.normalizePoi);
// Update cache
this.lastQueryKey = queryKey;
this.cachedResults = results;
return results;
} catch (e) { } catch (e) {
if (e.name === 'AbortError') { if (e.name === 'AbortError') {
console.log('Overpass request aborted'); console.log('Overpass request aborted');

View File

@@ -4,12 +4,14 @@ html,
body { body {
height: 100%; height: 100%;
overscroll-behavior: none; /* Prevent pull-to-refresh on mobile */ overscroll-behavior: none; /* Prevent pull-to-refresh on mobile */
-webkit-text-size-adjust: 100%;
} }
body { body {
margin: 0; margin: 0;
font-family: 'Noto Serif', serif; font-family: 'Noto Serif', sans-serif;
font-size: 16px; font-size: 16px;
color: #333;
} }
#root, #root,
@@ -95,7 +97,7 @@ body {
.user-avatar-placeholder { .user-avatar-placeholder {
width: 40px; width: 40px;
height: 40px; height: 40px;
background: #333; background: #2a3743;
border-radius: 50%; border-radius: 50%;
display: flex; display: flex;
align-items: center; align-items: center;
@@ -191,7 +193,6 @@ body {
bottom: 0; bottom: 0;
width: 300px; width: 300px;
background: white; background: white;
color: #333;
z-index: 3100; /* Higher than Header (3000) */ z-index: 3100; /* Higher than Header (3000) */
box-shadow: 2px 0 5px rgb(0 0 0 / 10%); box-shadow: 2px 0 5px rgb(0 0 0 / 10%);
display: flex; display: flex;
@@ -225,10 +226,15 @@ body {
.sidebar-header h2 { .sidebar-header h2 {
margin: 0; margin: 0;
font-size: 1.2rem; font-size: 1.2rem;
display: flex;
align-items: center;
gap: 0.5rem;
} }
.sidebar-content { .sidebar-content {
padding: 1rem; padding: 1rem;
overflow-y: auto;
flex: 1; /* Take up remaining vertical space */
} }
.edit-form { .edit-form {
@@ -256,7 +262,7 @@ body {
border: 1px solid #ddd; border: 1px solid #ddd;
border-radius: 4px; border-radius: 4px;
font-family: inherit; font-family: inherit;
font-size: 0.95rem; font-size: 1rem;
box-sizing: border-box; /* Ensure padding doesn't overflow width */ box-sizing: border-box; /* Ensure padding doesn't overflow width */
} }
@@ -320,6 +326,11 @@ body {
font-size: 0.9rem; font-size: 0.9rem;
} }
.meta-info p {
margin-top: 1rem;
margin-bottom: 1rem;
}
.meta-info p:first-child { .meta-info p:first-child {
margin-top: 1.2rem; margin-top: 1.2rem;
padding-top: 1.2rem; padding-top: 1.2rem;
@@ -359,29 +370,31 @@ body {
.places-list { .places-list {
list-style: none; list-style: none;
padding: 0; padding: 0;
margin: 0; margin: -1rem -1rem 0 -1rem;
} }
.places-list li { .places-list li {
margin-bottom: 0.5rem;
} }
.place-item { .place-item {
width: 100%; width: 100%;
text-align: left; text-align: left;
background: #f8f9fa; border: none;
border: 1px solid #ddd; border-bottom: 1px solid #eee;
padding: 0.75rem; background: #fff;
border-radius: 4px; color: #333;
padding: 1rem;
cursor: pointer; cursor: pointer;
transition: background 0.2s; transition: background 0.2s;
font-family: inherit;
} }
.place-item:hover { .place-item:hover {
background: #e9ecef; background: #eee;
} }
.place-name { .place-name {
font-size: 1rem;
font-weight: bold; font-weight: bold;
margin-bottom: 0.25rem; margin-bottom: 0.25rem;
} }
@@ -389,7 +402,6 @@ body {
.place-type { .place-type {
color: #666; color: #666;
font-size: 0.85rem; font-size: 0.85rem;
text-transform: capitalize;
} }
.back-btn { .back-btn {
@@ -423,12 +435,11 @@ body {
.place-details .place-type { .place-details .place-type {
color: #666; color: #666;
font-size: 0.9rem; font-size: 0.9rem;
text-transform: capitalize;
margin: 0 0 1rem; margin: 0 0 1rem;
} }
.place-details .place-description { .place-details p.place-description {
margin-bottom: 1.5rem; line-height: 1.4;
} }
.place-details .actions { .place-details .actions {
@@ -436,6 +447,7 @@ body {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
gap: 1rem; gap: 1rem;
margin-top: 1.5rem;
} }
.btn { .btn {
@@ -495,11 +507,15 @@ body {
border: 2px solid rgb(255 204 51 / 80%); /* Gold/Yellow to match markers */ border: 2px solid rgb(255 204 51 / 80%); /* Gold/Yellow to match markers */
background: rgb(255 204 51 / 20%); background: rgb(255 204 51 / 20%);
position: absolute; position: absolute;
transform: translate(-50%, -50%); /* Use translate3d for GPU acceleration on iOS */
transform: translate3d(-50%, -50%, 0);
pointer-events: none; pointer-events: none;
animation: pulse 1.5s infinite ease-out; animation: pulse 1.5s infinite ease-out;
box-sizing: border-box; /* Ensure border is included in width/height */ box-sizing: border-box; /* Ensure border is included in width/height */
display: none; /* Hidden by default */ display: none; /* Hidden by default */
will-change: transform, opacity;
-webkit-backface-visibility: hidden;
backface-visibility: hidden;
} }
.search-pulse.active { .search-pulse.active {
@@ -513,12 +529,12 @@ body {
@keyframes pulse { @keyframes pulse {
0% { 0% {
transform: translate(-50%, -50%) scale(0.8); transform: translate3d(-50%, -50%, 0) scale(0.8);
opacity: 0.8; opacity: 0.8;
} }
100% { 100% {
transform: translate(-50%, -50%) scale(1.4); transform: translate3d(-50%, -50%, 0) scale(1.4);
opacity: 0; opacity: 0;
} }
} }

View File

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

View File

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

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

@@ -0,0 +1,28 @@
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;
@action
selectPlace(place) {
if (place) {
this.router.transitionTo('place', place);
}
}
@action
close() {
this.router.transitionTo('index');
}
<template>
<PlacesSidebar
@places={{@model}}
@onSelect={{this.selectPlace}}
@onClose={{this.close}}
/>
</template>
}

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

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

View File

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

View File

@@ -1,6 +1,6 @@
{ {
"name": "marco", "name": "marco",
"version": "1.8.5", "version": "1.9.0",
"private": true, "private": true,
"description": "Unhosted maps app", "description": "Unhosted maps app",
"repository": { "repository": {
@@ -50,7 +50,7 @@
"@embroider/vite": "^1.5.0", "@embroider/vite": "^1.5.0",
"@eslint/js": "^9.39.2", "@eslint/js": "^9.39.2",
"@glimmer/component": "^2.0.0", "@glimmer/component": "^2.0.0",
"@remotestorage/module-places": "link:vendor/remotestorage-module-places", "@remotestorage/module-places": "1.x",
"@rollup/plugin-babel": "^6.1.0", "@rollup/plugin-babel": "^6.1.0",
"@warp-drive/core": "~5.8.0", "@warp-drive/core": "~5.8.0",
"@warp-drive/ember": "~5.8.0", "@warp-drive/ember": "~5.8.0",

18
pnpm-lock.yaml generated
View File

@@ -52,8 +52,8 @@ importers:
specifier: ^2.0.0 specifier: ^2.0.0
version: 2.0.0 version: 2.0.0
'@remotestorage/module-places': '@remotestorage/module-places':
specifier: link:vendor/remotestorage-module-places specifier: 1.x
version: link:vendor/remotestorage-module-places version: 1.0.0
'@rollup/plugin-babel': '@rollup/plugin-babel':
specifier: ^6.1.0 specifier: ^6.1.0
version: 6.1.0(@babel/core@7.28.6)(rollup@4.55.1) version: 6.1.0(@babel/core@7.28.6)(rollup@4.55.1)
@@ -1377,6 +1377,9 @@ packages:
resolution: {integrity: sha512-4rdu8GPY9TeQwsYp5D2My74dC3dSVS3tghAvisG80ybK4lqa0gvlrglaSTBxogJbxqHRw/NjI/liEtb3+SD+Bw==} resolution: {integrity: sha512-4rdu8GPY9TeQwsYp5D2My74dC3dSVS3tghAvisG80ybK4lqa0gvlrglaSTBxogJbxqHRw/NjI/liEtb3+SD+Bw==}
engines: {node: '>=18.12'} engines: {node: '>=18.12'}
'@remotestorage/module-places@1.0.0':
resolution: {integrity: sha512-vaqJeTw658gjPyLz70Mq2AbGfDZ66O2mpDFME+gtaGFYl2+UvrvRLCrXWHYuyTE21f3TJdegeXM6C5nZMxLv9A==}
'@rollup/plugin-babel@6.1.0': '@rollup/plugin-babel@6.1.0':
resolution: {integrity: sha512-dFZNuFD2YRcoomP4oYf+DvQNSUA9ih+A3vUqopQx5EdtPGo3WBnQcI/S8pwpz91UsGfL0HsMSOlaMld8HrbubA==} resolution: {integrity: sha512-dFZNuFD2YRcoomP4oYf+DvQNSUA9ih+A3vUqopQx5EdtPGo3WBnQcI/S8pwpz91UsGfL0HsMSOlaMld8HrbubA==}
engines: {node: '>=14.0.0'} engines: {node: '>=14.0.0'}
@@ -5180,6 +5183,10 @@ packages:
engines: {node: '>=0.8.0'} engines: {node: '>=0.8.0'}
hasBin: true hasBin: true
ulid@3.0.2:
resolution: {integrity: sha512-yu26mwteFYzBAot7KVMqFGCVpsF6g8wXfJzQUHvu1no3+rRRSFcSV2nKeYvNPLD2J4b08jYBDhHUjeH0ygIl9w==}
hasBin: true
underscore.string@3.3.6: underscore.string@3.3.6:
resolution: {integrity: sha512-VoC83HWXmCrF6rgkyxS9GHv8W9Q5nhMKho+OadDJGzL2oDYbYEppBaCMH6pFlwLeqj2QS+hhkw2kpXkSdD1JxQ==} resolution: {integrity: sha512-VoC83HWXmCrF6rgkyxS9GHv8W9Q5nhMKho+OadDJGzL2oDYbYEppBaCMH6pFlwLeqj2QS+hhkw2kpXkSdD1JxQ==}
@@ -6967,6 +6974,11 @@ snapshots:
'@pnpm/error': 1000.0.5 '@pnpm/error': 1000.0.5
find-up: 5.0.0 find-up: 5.0.0
'@remotestorage/module-places@1.0.0':
dependencies:
latlon-geohash: 2.0.0
ulid: 3.0.2
'@rollup/plugin-babel@6.1.0(@babel/core@7.28.6)(rollup@4.55.1)': '@rollup/plugin-babel@6.1.0(@babel/core@7.28.6)(rollup@4.55.1)':
dependencies: dependencies:
'@babel/core': 7.28.6 '@babel/core': 7.28.6
@@ -11449,6 +11461,8 @@ snapshots:
uglify-js@3.19.3: uglify-js@3.19.3:
optional: true optional: true
ulid@3.0.2: {}
underscore.string@3.3.6: underscore.string@3.3.6:
dependencies: dependencies:
sprintf-js: 1.1.3 sprintf-js: 1.1.3

View File

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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -9,7 +9,7 @@
<!-- App identity --> <!-- App identity -->
<meta name="application-name" content="Marco"> <meta name="application-name" content="Marco">
<meta name="apple-mobile-web-app-title" content="Marco"> <meta name="apple-mobile-web-app-title" content="Marco">
<meta name="theme-color" content="#333333"> <meta name="theme-color" content="#2a3743">
<!-- PWA Manifest --> <!-- PWA Manifest -->
<link rel="manifest" href="/web-app-manifest.json"> <link rel="manifest" href="/web-app-manifest.json">
@@ -26,8 +26,8 @@
<meta name="msapplication-TileColor" content="#F6E9A6"> <meta name="msapplication-TileColor" content="#F6E9A6">
<meta name="msapplication-TileImage" content="/icons/icon-144.png"> <meta name="msapplication-TileImage" content="/icons/icon-144.png">
<script type="module" crossorigin src="/assets/main-DsIm8MXw.js"></script> <script type="module" crossorigin src="/assets/main-DwYp7tls.js"></script>
<link rel="stylesheet" crossorigin href="/assets/main-Csx4lQiv.css"> <link rel="stylesheet" crossorigin href="/assets/main-BZSIy5va.css">
</head> </head>
<body> <body>
</body> </body>

View File

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

View File

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