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
**Last Updated:** Sat Jan 24 2026
**Last Updated:** Mon Jan 26 2026
## 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.
- **Feedback:** Implemented a "pulse" animation (via OpenLayers Overlay) at the click location to visualize the search radius (30m/50m).
- **Mobile UX:**
- 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`).
- **Touch:** Disabled browser tap highlights (`-webkit-tap-highlight-color: transparent`) to prevent blue flashing on Android.
- **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.
- **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"):**
- 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).
@@ -44,7 +49,7 @@ We are building **Marco**, a decentralized maps application using **Ember.js** (
- configured with `maxAge: false` to ensure data freshness.
- **Dependencies:** Uses `ulid` and `latlon-geohash` internally.
### 3. App Infrastructure
### 3. App Infrastructure & Build
- **Services:**
- `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:**
- `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.
- **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
@@ -91,16 +101,17 @@ We are building **Marco**, a decentralized maps application using **Ember.js** (
## Files Currently in Focus
- `app/templates/application.gjs`: Core layout and "Outside Click" logic.
- `app/components/settings-pane.gjs`: Settings UI.
- `app/services/settings.js`: Settings persistence.
- `app/styles/app.css`: Mobile CSS fixes (font sizes, control positioning).
- `package.json`: New scripts and dependencies.
- `README.md`: Updated documentation.
## Next Steps & Pending Tasks
1. **Collections/Lists:** Implement ability to organize bookmarks into lists/collections.
2. **Linting & Code Quality:** Fix remaining CSS errors, remove inline styles in `map.gjs`, and address unused variables/runloop usage.
3. **Performance:** Monitor performance with large datasets (thousands of bookmarks).
4. **Testing:** Add automated tests for the geohash coverage, retry logic, and new editing features.
1. **Mobile Polish:** Verify "Locate Me" animation on iOS Safari.
2. **Collections/Lists:** Implement ability to organize bookmarks into lists/collections.
3. **Linting & Code Quality:** Fix remaining CSS errors, remove inline styles in `map.gjs`, and address unused variables/runloop usage.
4. **Performance:** Monitor performance with large datasets (thousands of bookmarks).
5. **Testing:** Add automated tests for the geohash coverage, retry logic, and new editing features.
## Technical Constraints

View File

@@ -73,6 +73,7 @@ To run the script, you need `imagemagick` and `librsvg` installed:
- [ember.js](https://emberjs.com/)
- [remoteStorage.js](https://remotestorage.io/rs.js/docs/)
- [@remotestorage/module-places](https://gitea.kosmos.org/raucao/remotestorage-module-places)
- [Vite](https://vite.dev)
- Development Browser Extensions
- [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 server from 'feather-icons/dist/icons/server.svg?raw';
import settings from 'feather-icons/dist/icons/settings.svg?raw';
import target from 'feather-icons/dist/icons/target.svg?raw';
import user from 'feather-icons/dist/icons/user.svg?raw';
import x from 'feather-icons/dist/icons/x.svg?raw';
import zap from 'feather-icons/dist/icons/zap.svg?raw';
@@ -38,6 +39,7 @@ const ICONS = {
phone,
server,
settings,
target,
user,
x,
zap,

View File

@@ -21,6 +21,7 @@ export default class MapComponent extends Component {
@service osm;
@service storage;
@service mapUi;
@service router;
mapInstance;
bookmarkSource;
@@ -61,8 +62,8 @@ export default class MapComponent extends Component {
});
// Default view settings
let center = [99.05738, 7.55087];
let zoom = 13.0;
let center = [14.21683569, 27.060114248];
let zoom = 2.661;
// Try to restore from localStorage
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 () => {
if (!this.mapInstance) return;
@@ -546,7 +558,6 @@ export default class MapComponent extends Component {
});
let clickedBookmark = null;
let selectedFeatureName = null;
let selectedFeatureType = null;
if (features && features.length > 0) {
console.debug(`Found ${features.length} features in map layer:`);
@@ -561,7 +572,6 @@ export default class MapComponent extends Component {
const props = features[0].getProperties();
if (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):',
clickedBookmark
);
if (this.args.onPlacesFound) {
this.args.onPlacesFound([], clickedBookmark);
}
this.router.transitionTo('place', clickedBookmark);
return;
}
@@ -589,9 +597,7 @@ export default class MapComponent extends Component {
// Normal behavior (sidebar is closed)
if (clickedBookmark) {
console.log('Clicked bookmark:', clickedBookmark);
if (this.args.onPlacesFound) {
this.args.onPlacesFound([], clickedBookmark);
}
this.router.transitionTo('place', clickedBookmark);
return;
}
@@ -615,76 +621,21 @@ export default class MapComponent extends Component {
this.searchOverlayElement.style.width = `${diameterInPixels}px`;
this.searchOverlayElement.style.height = `${diameterInPixels}px`;
this.searchOverlay.setPosition(event.coordinate);
this.searchOverlayElement.classList.add('active');
}
// 2. Fetch authoritative data via Overpass
try {
let pois = await this.osm.getNearbyPois(lat, lon, searchRadius);
// Start Search State
this.mapUi.startSearch();
// Sort by distance from click
pois = pois
.map((p) => {
// p is already normalized by service, so lat/lon are at top level
return {
...p,
_distance: getDistance(lat, lon, p.lat, p.lon),
};
})
.sort((a, b) => a._distance - b._distance);
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');
}
// Transition to Search Route
const queryParams = {
lat: lat.toFixed(6),
lon: lon.toFixed(6),
};
if (selectedFeatureName) {
queryParams.q = selectedFeatureName;
}
this.router.transitionTo('search', { queryParams });
};
<template>
@@ -693,6 +644,7 @@ export default class MapComponent extends Component {
{{this.setupMap}}
{{this.updateBookmarks}}
{{this.updateSelectedPin}}
{{this.syncPulse}}
></div>
</template>
}

View File

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

View File

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

View File

@@ -49,6 +49,8 @@ export default class PlaceRoute extends Route {
if (model) {
this.mapUi.selectPlace(model);
}
// Stop the pulse animation if it was running (e.g. redirected from search)
this.mapUi.stopSearch();
}
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 {
@tracked selectedPlace = null;
@tracked isSearching = false;
selectPlace(place) {
this.selectedPlace = place;
@@ -11,4 +12,12 @@ export default class MapUiService extends Service {
clearSelection() {
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;
controller = null;
cachedResults = null;
lastQueryKey = null;
async getNearbyPois(lat, lon, radius = 50) {
const queryKey = `${lat},${lon},${radius}`;
// Return cached results if the query is identical to the last one
if (this.lastQueryKey === queryKey && this.cachedResults) {
console.log('Returning cached Overpass results for:', queryKey);
return this.cachedResults;
}
// Cancel previous request if it exists
if (this.controller) {
this.controller.abort();
@@ -33,7 +43,13 @@ out center;
const data = await res.json();
// Normalize data
return data.elements.map(this.normalizePoi);
const results = data.elements.map(this.normalizePoi);
// Update cache
this.lastQueryKey = queryKey;
this.cachedResults = results;
return results;
} catch (e) {
if (e.name === 'AbortError') {
console.log('Overpass request aborted');

View File

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

View File

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

View File

@@ -7,6 +7,7 @@ import { tracked } from '@glimmer/tracking';
export default class PlaceTemplate extends Component {
@service router;
@service storage;
@service mapUi;
@tracked localPlace = null;
@@ -72,8 +73,26 @@ export default class PlaceTemplate extends Component {
this.storage.notifyChange();
}
@action
navigateBack(place) {
// The sidebar calls this with null when "Back" is clicked.
if (place === null) {
// If we 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
close() {
// Clear search results so we don't fall back to the list
this.router.transitionTo('index');
}
@@ -81,6 +100,7 @@ export default class PlaceTemplate extends Component {
<PlacesSidebar
@selectedPlace={{this.place}}
@onClose={{this.close}}
@onSelect={{this.navigateBack}}
@onBookmarkChange={{this.refreshMap}}
@onUpdate={{this.handleUpdate}}
/>

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 -->
<meta name="application-name" content="Marco">
<meta name="apple-mobile-web-app-title" content="Marco">
<meta name="theme-color" content="#333333">
<meta name="theme-color" content="#2a3743">
<!-- PWA Manifest -->
<link rel="manifest" href="/web-app-manifest.json">

View File

@@ -1,6 +1,6 @@
{
"name": "marco",
"version": "1.8.5",
"version": "1.9.0",
"private": true,
"description": "Unhosted maps app",
"repository": {
@@ -50,7 +50,7 @@
"@embroider/vite": "^1.5.0",
"@eslint/js": "^9.39.2",
"@glimmer/component": "^2.0.0",
"@remotestorage/module-places": "link:vendor/remotestorage-module-places",
"@remotestorage/module-places": "1.x",
"@rollup/plugin-babel": "^6.1.0",
"@warp-drive/core": "~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
version: 2.0.0
'@remotestorage/module-places':
specifier: link:vendor/remotestorage-module-places
version: link:vendor/remotestorage-module-places
specifier: 1.x
version: 1.0.0
'@rollup/plugin-babel':
specifier: ^6.1.0
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==}
engines: {node: '>=18.12'}
'@remotestorage/module-places@1.0.0':
resolution: {integrity: sha512-vaqJeTw658gjPyLz70Mq2AbGfDZ66O2mpDFME+gtaGFYl2+UvrvRLCrXWHYuyTE21f3TJdegeXM6C5nZMxLv9A==}
'@rollup/plugin-babel@6.1.0':
resolution: {integrity: sha512-dFZNuFD2YRcoomP4oYf+DvQNSUA9ih+A3vUqopQx5EdtPGo3WBnQcI/S8pwpz91UsGfL0HsMSOlaMld8HrbubA==}
engines: {node: '>=14.0.0'}
@@ -5180,6 +5183,10 @@ packages:
engines: {node: '>=0.8.0'}
hasBin: true
ulid@3.0.2:
resolution: {integrity: sha512-yu26mwteFYzBAot7KVMqFGCVpsF6g8wXfJzQUHvu1no3+rRRSFcSV2nKeYvNPLD2J4b08jYBDhHUjeH0ygIl9w==}
hasBin: true
underscore.string@3.3.6:
resolution: {integrity: sha512-VoC83HWXmCrF6rgkyxS9GHv8W9Q5nhMKho+OadDJGzL2oDYbYEppBaCMH6pFlwLeqj2QS+hhkw2kpXkSdD1JxQ==}
@@ -6967,6 +6974,11 @@ snapshots:
'@pnpm/error': 1000.0.5
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)':
dependencies:
'@babel/core': 7.28.6
@@ -11449,6 +11461,8 @@ snapshots:
uglify-js@3.19.3:
optional: true
ulid@3.0.2: {}
underscore.string@3.3.6:
dependencies:
sprintf-js: 1.1.3

View File

@@ -6,7 +6,7 @@
"scope": "/",
"display": "standalone",
"background_color": "#f8f9fa",
"theme_color": "#333333",
"theme_color": "#2a3743",
"icons": [
{
"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 -->
<meta name="application-name" content="Marco">
<meta name="apple-mobile-web-app-title" content="Marco">
<meta name="theme-color" content="#333333">
<meta name="theme-color" content="#2a3743">
<!-- PWA Manifest -->
<link rel="manifest" href="/web-app-manifest.json">
@@ -26,8 +26,8 @@
<meta name="msapplication-TileColor" content="#F6E9A6">
<meta name="msapplication-TileImage" content="/icons/icon-144.png">
<script type="module" crossorigin src="/assets/main-DsIm8MXw.js"></script>
<link rel="stylesheet" crossorigin href="/assets/main-Csx4lQiv.css">
<script type="module" crossorigin src="/assets/main-DwYp7tls.js"></script>
<link rel="stylesheet" crossorigin href="/assets/main-BZSIy5va.css">
</head>
<body>
</body>

View File

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

View File

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