16 Commits

Author SHA1 Message Date
026d1c4712 1.4.2 2026-01-23 12:59:37 +07:00
6bd55843bb Switch to bkero's API (for now) 2026-01-23 12:59:11 +07:00
33a6469a19 Various layout and style improvements for place details 2026-01-23 12:41:27 +07:00
6d7bea411a 1.4.1 2026-01-23 10:21:25 +07:00
7b01bb1118 Fix place store/remove behavior 2026-01-23 10:21:02 +07:00
84d4f9cbbf 1.4.0 2026-01-22 17:35:06 +07:00
f7e7480e51 Pan map to bring loaded place into view if necessary 2026-01-22 17:34:19 +07:00
6e87ef3573 Load all saved place into memory
Fixes launching the app with a place URL directly, and will be useful
for search etc. later.
2026-01-22 17:23:50 +07:00
86b85e9a0b Ignore release dir for linting etc. 2026-01-22 16:52:26 +07:00
2a203e8e82 Add initialSyncDone property to storage service
Allows us to know when the first sync cycle has been completed
2026-01-22 16:40:02 +07:00
b08dcedd13 Slightly brighter icon color 2026-01-22 16:39:26 +07:00
5267ffdd5c Log map features on click 2026-01-22 14:54:01 +07:00
deae2260b1 Fix occasional exception on mobiles 2026-01-22 14:40:35 +07:00
3c5b4d9b98 Update status doc 2026-01-21 22:00:34 +07:00
b419b498da 1.3.2 2026-01-21 19:58:08 +07:00
be921cf3ca Prevent pull-to-refresh on mobile 2026-01-21 19:57:34 +07:00
21 changed files with 309 additions and 129 deletions

View File

@@ -3,6 +3,7 @@
# compiled output
/dist/
/release/
# misc
/coverage/

View File

@@ -3,3 +3,4 @@
# compiled output
/dist/
/release/

View File

@@ -19,7 +19,10 @@ We are building **Marco**, a decentralized maps application using **Ember.js** (
- **Optimization:** Added **10px hit tolerance** for easier tapping on mobile devices.
- **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.
- **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`).
- **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.
- **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).
@@ -27,6 +30,7 @@ We are building **Marco**, a decentralized maps application using **Ember.js** (
- **Auto-Stop:** Pulse and tracking automatically stop when high accuracy (≤20m) is achieved or after a 10s timeout.
- **Persistence:** Saves and restores map center and zoom level using `localStorage` (key: `marco:map-view`).
- **Controls:** Enabled standard OpenLayers Rotate control (re-north) and custom Locate control.
- **Pin Animation:** Selected pins are highlighted with a custom **Red Pin** overlay that drops in with an animation. The center dot is styled as a solid dark red circle (`#b31412`).
### 2. RemoteStorage Module (`@remotestorage/module-places`)
@@ -49,6 +53,7 @@ We are building **Marco**, a decentralized maps application using **Ember.js** (
- **Reliability:** Implemented `fetchWithRetry` to handle HTTP 504/502/503 timeouts and 429 rate limits, in addition to network errors.
- **UI Components:**
- `places-sidebar.gjs`: Displays a list of nearby POIs.
- **Layout:** Responsive design that transforms into a **Bottom Sheet** (50% height) on mobile screens (`<=768px`) with rounded corners and upward shadow.
- `place-details.gjs`: Dedicated component for displaying rich place information.
- **Features:** Icons (via `feather-icons`), Address, Phone, Website, Opening Hours, Cuisine, Wikipedia.
- **Layout:** Polished UI with distinct sections for Actions and Meta info.
@@ -59,6 +64,7 @@ We are building **Marco**, a decentralized maps application using **Ember.js** (
### 4. Routing & Data Optimization
- **Explicit URLs:** Implemented routing support for specific OSM entities via `/place/osm:node:<id>` and `/place/osm:way:<id>`, distinguishing them from local bookmarks (ULIDs).
- **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.
- **Performance:** Optimized navigation to prevent redundant network requests. Clicking a map pin passes the existing data object to the route, skipping the `model` hook (no re-fetch) while maintaining correct deep-linkable URLs via a custom `serialize` hook in `PlaceRoute`.
@@ -68,24 +74,25 @@ We are building **Marco**, a decentralized maps application using **Ember.js** (
- **Workflow:**
1. User pans map -> `moveend` triggers `storage.loadPlacesInBounds`.
2. User clicks map -> "Pulse" animation -> hybrid hit detection (Visual Tile vs Overpass).
3. **Navigation:** Selected place is passed to the route (`transitionTo` with model), updating the URL to `/place/<id>` or `/place/osm:<type>:<id>` without re-fetching data.
4. Sidebar displays details via `<PlaceDetails>` component.
3. **Navigation:** Selected place is checked against bookmarks; if found, it uses the Bookmark object. Otherwise, it uses the OSM object.
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.
## Files Currently in Focus
- `app/components/place-details.gjs`: UI logic for place info.
- `app/routes/place.js`: Routing logic.
- `app/components/map.gjs`: Map rendering and interaction.
- `app/styles/app.css`: Responsive sidebar styles and mobile optimizations.
- `app/components/map.gjs`: Map rendering, interaction, and mobile auto-panning.
- `app/templates/application.gjs`: Root template handling place selection logic.
- `app/services/storage.js`: Data sync logic.
## Next Steps & Pending Tasks
1. **App Header:** Implement a transparent header bar with the App Logo (left) and Login/User Info (right).
2. **Edit Bookmarks:** Allow users to edit the title and description of saved places.
3. **Performance:** Monitor performance with large datasets (thousands of bookmarks).
4. **Testing:** Add automated tests for the geohash coverage and retry logic.
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 and retry logic.
## Technical Constraints

View File

@@ -33,7 +33,7 @@ export default class IconComponent extends Component {
}
get color() {
return this.args.color || '#888';
return this.args.color || '#898989';
}
get style() {

View File

@@ -114,7 +114,7 @@ export default class MapComponent extends Component {
// Using JS creation to ensure it's cleanly managed by OpenLayers
this.selectedPinElement = document.createElement('div');
this.selectedPinElement.className = 'selected-pin-container';
// Create the icon structure inside
const pinIcon = document.createElement('div');
pinIcon.className = 'selected-pin';
@@ -123,7 +123,7 @@ export default class MapComponent extends Component {
// Feather icons are globally available if we used the script, but we are using the module approach.
// Simple SVG for Map Pin:
pinIcon.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"></path><circle cx="12" cy="10" r="3" style="fill: #b31412; stroke: none;"></circle></svg>`;
const pinShadow = document.createElement('div');
pinShadow.className = 'selected-pin-shadow';
@@ -353,21 +353,79 @@ export default class MapComponent extends Component {
if (selected && selected.lat && selected.lon) {
const coords = fromLonLat([selected.lon, selected.lat]);
this.selectedPinOverlay.setPosition(coords);
// Reset animation by removing/adding class
this.selectedPinElement.classList.remove('active');
// Force reflow
void this.selectedPinElement.offsetWidth;
this.selectedPinElement.classList.add('active');
this.panIfObscured(coords);
this.handlePinVisibility(coords);
} else {
this.selectedPinElement.classList.remove('active');
// Hide it effectively by moving it away or just relying on display:none in CSS
this.selectedPinOverlay.setPosition(undefined);
this.selectedPinOverlay.setPosition(undefined);
}
});
handlePinVisibility(coords) {
if (!this.mapInstance) return;
const pixel = this.mapInstance.getPixelFromCoordinate(coords);
const size = this.mapInstance.getSize();
// Check if off-screen (not rendered or outside bounds)
const isOffScreen =
!pixel ||
pixel[0] < 0 ||
pixel[0] > size[0] ||
pixel[1] < 0 ||
pixel[1] > size[1];
if (isOffScreen) {
this.animateToSmartCenter(coords);
} else {
this.panIfObscured(coords);
}
}
animateToSmartCenter(coords) {
if (!this.mapInstance) return;
const size = this.mapInstance.getSize();
const view = this.mapInstance.getView();
const resolution = view.getResolution();
let targetCenter = coords;
// Check if mobile (width <= 768px matches CSS)
if (size[0] <= 768) {
// On mobile, the bottom 50% is covered by the sheet.
// We want the pin to be in the center of the TOP 50% (visible area).
// That means the pin should be at y = height * 0.25 (25% down from top).
// The map center is at y = height * 0.50.
// So the pin is "above" the center by 25% of the height in pixels.
// To put the pin there, the map center needs to be "below" the pin by that amount.
const height = size[1];
const offsetPixels = height * 0.25; // Distance from desired pin pos to map center
const offsetMapUnits = offsetPixels * resolution;
// Shift center SOUTH (decrease Y)
// Note: In Web Mercator (EPSG:3857), Y increases North.
// So to look "lower", we decrease Y? No wait.
// If we move the camera South (decrease Y), the features move North (Up) on screen.
// We want the Pin (fixed lat/lon) to be Higher up on screen.
// So we must move the Camera South (Lower Y).
targetCenter = [coords[0], coords[1] - offsetMapUnits];
}
view.animate({
center: targetCenter,
duration: 1000,
easing: (t) => t * (2 - t), // Ease-out
});
}
panIfObscured(coords) {
if (!this.mapInstance) return;
@@ -376,25 +434,27 @@ export default class MapComponent extends Component {
if (size[0] > 768) return;
const pixel = this.mapInstance.getPixelFromCoordinate(coords);
if (!pixel) return;
const height = size[1];
// Sidebar covers the bottom 50%
const splitPoint = height / 2;
// If the pin is in the bottom half (y > splitPoint), it is obscured
if (pixel[1] > splitPoint) {
// Target position: Center of top half = height * 0.25
const targetY = height * 0.25;
const deltaY = pixel[1] - targetY;
const view = this.mapInstance.getView();
const center = view.getCenter();
const resolution = view.getResolution();
// Move the map center SOUTH (decrease Y) to move the pin UP (decrease pixel Y)
const deltaMapUnits = deltaY * resolution;
const newCenter = [center[0], center[1] - deltaMapUnits];
view.animate({
center: newCenter,
duration: 500,
@@ -405,8 +465,8 @@ export default class MapComponent extends Component {
// Re-fetch bookmarks when the version changes (triggered by parent action or service)
updateBookmarks = modifier(() => {
// Depend on the tracked storage.savedPlaces to automatically update when they change
const places = this.storage.savedPlaces;
// Depend on the tracked storage.placesInView to automatically update when they change
const places = this.storage.placesInView;
this.loadBookmarks(places);
});
@@ -416,13 +476,13 @@ export default class MapComponent extends Component {
if (!places || places.length === 0) {
// Fallback or explicit check if we have tracked property usage?
// The service updates 'savedPlaces'. We should probably use that if we want reactiveness.
places = this.storage.savedPlaces;
// The service updates 'placesInView'. We should probably use that if we want reactiveness.
places = this.storage.placesInView;
}
// Previously: const places = await this.storage.places.getPlaces();
// We no longer want to fetch everything blindly.
// We rely on 'savedPlaces' being updated by handleMapMove calling storage.loadPlacesInBounds.
// We rely on 'placesInView' being updated by handleMapMove calling storage.loadPlacesInBounds.
this.bookmarkSource.clear();
@@ -455,7 +515,7 @@ export default class MapComponent extends Component {
const bbox = { minLat, minLon, maxLat, maxLon };
await this.storage.loadPlacesInBounds(bbox);
this.loadBookmarks(this.storage.savedPlaces);
this.loadBookmarks(this.storage.placesInView);
// Persist view to localStorage
try {
@@ -484,6 +544,8 @@ export default class MapComponent extends Component {
let selectedFeatureType = null;
if (features && features.length > 0) {
console.debug(`Found ${features.length} features in map layer:`);
for (const f of features) { console.debug(f) }
const bookmarkFeature = features.find((f) => f.get('isBookmark'));
if (bookmarkFeature) {
clickedBookmark = bookmarkFeature.get('originalPlace');

View File

@@ -67,6 +67,11 @@ export default class PlaceDetails extends Component {
return this.place.url || this.tags.website || this.tags['contact:website'];
}
get websiteDomain() {
const url = new URL(this.website);
return url.hostname;
}
get openingHours() {
return this.tags.opening_hours;
}
@@ -106,6 +111,10 @@ export default class PlaceDetails extends Component {
return `https://www.openstreetmap.org/${type}/${id}`;
}
get gmapsUrl() {
return `https://www.google.com/maps/search/?api=1&query=${this.name}&query=${this.place.lat},${this.place.lon}`;
}
<template>
<div class="place-details">
<h3>{{this.name}}</h3>
@@ -129,6 +138,7 @@ export default class PlaceDetails extends Component {
</div>
<div class="meta-info">
{{#if this.cuisine}}
<p>
<strong>Cuisine:</strong>
@@ -153,7 +163,7 @@ export default class PlaceDetails extends Component {
{{#if this.website}}
<p class="content-with-icon">
<Icon @name="globe" @title="Website" />
<span><a href={{this.website}} target="_blank" rel="noopener noreferrer">Website</a></span>
<span><a href={{this.website}} target="_blank" rel="noopener noreferrer">{{this.websiteDomain}}</a></span>
</p>
{{/if}}
@@ -164,7 +174,8 @@ export default class PlaceDetails extends Component {
</p>
{{/if}}
<hr class="meta-divider">
</div>
<div class="meta-info">
{{#if this.address}}
<p class="content-with-icon">
@@ -192,6 +203,16 @@ export default class PlaceDetails extends Component {
</span>
</p>
{{/if}}
<p class="content-with-icon">
<Icon @name="map" @title="OSM ID" />
<span>
<a href={{this.gmapsUrl}} target="_blank" rel="noopener noreferrer">
Google Maps
</a>
</span>
</p>
</div>
</div>
</template>

View File

@@ -31,70 +31,46 @@ export default class PlacesSidebar extends Component {
}
}
get geoLink() {
if (!this.args.selectedPlace) return '#';
const p = this.args.selectedPlace;
// geo:lat,lon?q=lat,lon(Label)
const label = encodeURIComponent(
p.title ||
p.tags?.name ||
p.tags?.['name:en'] ||
'Location'
);
return `geo:${p.lat},${p.lon}?q=${p.lat},${p.lon}(${label})`;
}
get visibleGeoLink() {
if (!this.args.selectedPlace) return '';
const p = this.args.selectedPlace;
return `geo:${p.lat},${p.lon}`;
}
@action
async toggleSave(place) {
if (!place) return;
if (place.createdAt) {
// It's a saved bookmark -> Delete it
if (confirm(`Delete "${place.title}"?`)) {
try {
if (place.id && place.geohash) {
await this.storage.places.remove(place.id, place.geohash);
console.log('Place deleted:', place.title);
await this.storage.removePlace(place);
console.log('Place deleted:', place.title);
// Notify parent to refresh map bookmarks
if (this.args.onBookmarkChange) {
this.args.onBookmarkChange();
}
// Notify parent to refresh map bookmarks
if (this.args.onBookmarkChange) {
this.args.onBookmarkChange();
}
// Update selection to the new saved place object
// This updates the local UI state immediately without a route refresh
if (this.args.onUpdate) {
// When deleting, we revert to a "fresh" object or just close.
// Since we close the sidebar below, we might not strictly need to update local state,
// but it's good practice.
// Reconstruct the "original" place without ID/Geohash/CreatedAt
const freshPlace = {
...place,
id: undefined,
geohash: undefined,
createdAt: undefined
};
this.args.onUpdate(freshPlace);
}
// Update selection to the new saved place object
// This updates the local UI state immediately without a route refresh
if (this.args.onUpdate) {
// When deleting, we revert to a "fresh" object or just close.
// Since we close the sidebar below, we might not strictly need to update local state,
// but it's good practice.
// Reconstruct the "original" place without ID/Geohash/CreatedAt
const freshPlace = {
...place,
id: undefined,
geohash: undefined,
createdAt: undefined
};
this.args.onUpdate(freshPlace);
}
// Also fire onSelect if it exists (for list view)
if (this.args.onSelect) {
// Similar logic for select if needed, but we usually close.
this.args.onSelect(null);
}
// Also fire onSelect if it exists (for list view)
if (this.args.onSelect) {
// Similar logic for select if needed, but we usually close.
this.args.onSelect(null);
}
// Close sidebar after delete
if (this.args.onClose) {
this.args.onClose();
}
} else {
alert('Cannot delete: Missing ID or Geohash');
// Close sidebar after delete
if (this.args.onClose) {
this.args.onClose();
}
} catch (e) {
console.error('Failed to delete:', e);
@@ -115,7 +91,7 @@ export default class PlacesSidebar extends Component {
};
try {
const savedPlace = await this.storage.places.store(placeData);
const savedPlace = await this.storage.storePlace(placeData);
console.log('Place saved:', placeData.title);
// Notify parent to refresh map bookmarks
@@ -148,7 +124,6 @@ export default class PlacesSidebar extends Component {
class="back-btn"
{{on "click" this.clearSelection}}
>←</button>
<h2>Details</h2>
{{else}}
<h2>Nearby Places</h2>
{{/if}}
@@ -161,9 +136,9 @@ export default class PlacesSidebar extends Component {
<div class="sidebar-content">
{{#if @selectedPlace}}
<PlaceDetails
@place={{@selectedPlace}}
@onToggleSave={{this.toggleSave}}
<PlaceDetails
@place={{@selectedPlace}}
@onToggleSave={{this.toggleSave}}
/>
{{else}}
{{#if @places}}

View File

@@ -9,14 +9,14 @@ export default class PlaceRoute extends Route {
async model(params) {
const id = params.place_id;
// Check for explicit OSM prefixes
if (id.startsWith('osm:node:') || id.startsWith('osm:way:')) {
const [, type, osmId] = id.split(':');
console.log(`Fetching explicit OSM ${type}:`, osmId);
return this.loadOsmPlace(osmId, type);
}
// 1. Try to find in local bookmarks
await this.waitForSync();
let bookmark = this.storage.findPlaceById(id);
if (bookmark) {
@@ -24,9 +24,24 @@ export default class PlaceRoute extends Route {
return bookmark;
}
// 2. Fallback: Fetch from OSM (assuming generic ID or old format)
console.log('Not in bookmarks, fetching from OSM:', id);
return this.loadOsmPlace(id);
console.warn('Not in bookmarks:', id);
return null;
}
async waitForSync() {
if (this.storage.initialSyncDone) return;
console.log('Waiting for initial storage sync...');
const timeout = 5000;
const start = Date.now();
while (!this.storage.initialSyncDone) {
if (Date.now() - start > timeout) {
console.warn('Timed out waiting for initial sync');
break;
}
await new Promise((resolve) => setTimeout(resolve, 100));
}
}
afterModel(model) {

View File

@@ -23,7 +23,8 @@ export default class OsmService extends Service {
out center;
`.trim();
const url = `https://overpass-api.de/api/interpreter?data=${encodeURIComponent(
const url = `https://overpass.bke.ro/api/interpreter?data=${encodeURIComponent(
// const url = `https://overpass-api.de/api/interpreter?data=${encodeURIComponent(
query
)}`;

View File

@@ -8,10 +8,12 @@ import Geohash from 'latlon-geohash';
export default class StorageService extends Service {
rs;
@tracked placesInView = [];
@tracked savedPlaces = [];
@tracked loadedPrefixes = [];
@tracked currentBbox = null;
@tracked version = 0; // Shared version tracker for bookmarks
@tracked initialSyncDone = false;
constructor() {
super(...arguments);
@@ -31,14 +33,63 @@ export default class StorageService extends Service {
// widget.attach();
this.rs.on('ready', () => {
// this.loadAllPlaces();
// console.debug('[rs] client ready');
});
this.rs.on('sync-done', (result) => {
// console.debug('[rs] sync done:', result);
if (!this.initialSyncDone) {
this.initialSyncDone = true;
}
});
this.rs.scope('/places/').on('change', (event) => {
// console.debug(event);
this.handlePlaceChange(event);
debounce(this, this.reloadCurrentView, 200);
});
}
handlePlaceChange(event) {
const { newValue, relativePath } = event;
// Remove old entry if exists
// The relativePath is like "geohash/geohash/ULID" or just "ULID" depending on structure.
// Our structure is <2-char>/<2-char>/<id>.
// But let's rely on the ID inside the object if possible, or extract from path.
// We can't easily identify the ID from just relativePath without parsing logic if it's nested.
// However, for deletions (newValue is undefined), we might need the ID.
// Fortunately, our objects (newValue) contain the ID.
// If it's a deletion, we need to find the object in our array to remove it.
// Since we don't have the ID in newValue (it's null), we rely on `relativePath`.
// Let's assume the filename is the ID.
const pathParts = relativePath.split('/');
const id = pathParts[pathParts.length - 1];
if (!newValue) {
// Deletion
this.savedPlaces = this.savedPlaces.filter((p) => p.id !== id);
} else {
// Add or Update
// Ensure the object has the ID (it should)
const place = { ...newValue, id };
// Update existing or add new
const index = this.savedPlaces.findIndex((p) => p.id === id);
if (index !== -1) {
// Replace
const newPlaces = [...this.savedPlaces];
newPlaces[index] = place;
this.savedPlaces = newPlaces;
} else {
// Add
this.savedPlaces = [...this.savedPlaces, place];
}
}
}
get places() {
return this.rs.places;
}
@@ -98,7 +149,7 @@ export default class StorageService extends Service {
// Identify existing places that belong to the reloaded prefixes and remove them
const prefixSet = new Set(prefixes);
const keptPlaces = this.savedPlaces.filter((place) => {
const keptPlaces = this.placesInView.filter((place) => {
if (!place.lat || !place.lon) return false;
try {
// Calculate 4-char geohash for the existing place
@@ -112,27 +163,41 @@ export default class StorageService extends Service {
});
// Merge the kept places (from other areas) with the fresh places (from these areas)
this.savedPlaces = [...keptPlaces, ...places];
this.placesInView = [...keptPlaces, ...places];
} else {
// Full reload
this.savedPlaces = places;
this.placesInView = places;
}
} else {
if (!prefixes) this.savedPlaces = [];
if (!prefixes) this.placesInView = [];
}
console.log('Loaded saved places:', this.savedPlaces.length);
console.log('Loaded saved places:', this.placesInView.length);
} catch (e) {
console.error('Failed to load places:', e);
}
}
findPlaceById(id) {
// Search by internal ID first
let place = this.savedPlaces.find((p) => p.id === id);
if (!id) return undefined;
const strId = String(id);
// Search by internal ID first (loose comparison via string cast)
let place = this.savedPlaces.find((p) => p.id && String(p.id) === strId);
if (place) return place;
// Then search by OSM ID
place = this.savedPlaces.find((p) => p.osmId === id);
place = this.savedPlaces.find((p) => p.osmId && String(p.osmId) === strId);
return place;
}
async storePlace(placeData) {
const savedPlace = await this.places.store(placeData);
this.savedPlaces = [...this.savedPlaces, savedPlace];
return savedPlace;
}
async removePlace(place) {
await this.places.remove(place.id, place.geohash);
this.savedPlaces = this.savedPlaces.filter(p => p.id !== place.id);
}
}

View File

@@ -3,6 +3,7 @@
html,
body {
height: 100%;
overscroll-behavior: none; /* Prevent pull-to-refresh on mobile */
}
body {
@@ -96,6 +97,10 @@ body {
.place-details .place-description {
}
.place-details .actions {
padding-bottom: 0.3rem;
}
.btn-primary {
background: #007bff;
color: white;
@@ -148,13 +153,16 @@ body {
}
.meta-info {
margin-top: 1.5rem;
padding-top: 1rem;
border-top: 1px solid #eee;
font-size: 0.9rem;
text-align: left;
}
.meta-info p:first-child {
margin-top: 1.2rem;
padding-top: 1.2rem;
border-top: 1px solid #eee;
}
.meta-info p {
margin: 0.75rem 0;
line-height: 1.4;
@@ -168,18 +176,13 @@ body {
.meta-info a {
color: #007bff;
text-decoration: none;
padding-bottom: 4rem;
}
.meta-info a:hover {
text-decoration: underline;
}
.meta-divider {
border: 0;
border-top: 1px dashed #ddd;
margin: 1rem 0;
}
/* Map Search Pulse Animation */
.search-pulse {
border-radius: 50%;
@@ -328,6 +331,7 @@ span.icon {
.sidebar-content {
overflow-y: auto;
overscroll-behavior: contain; /* Prevent scroll chaining */
/* Ensure content doesn't get hidden behind bottom safe areas on mobile */
padding-bottom: env(safe-area-inset-bottom, 20px);
}

View File

@@ -23,20 +23,37 @@ export default class PlaceTemplate extends Component {
// Let's use a modifier or just sync it.
get place() {
// If we have a manually updated place (from save), use it.
// Otherwise use the route model.
// We need to ensure we reset `localPlace` when navigating to a NEW place.
// Comparing IDs is a safe bet.
// 1. Resolve the ID from the model (OSM ID or internal ID)
const model = this.args.model;
if (
this.localPlace &&
(this.localPlace.id === model.id || this.localPlace.osmId === model.osmId)
) {
// If the local place is "richer" (has createdAt), prefer it.
if (this.localPlace.createdAt && !model.createdAt) return this.localPlace;
// If we deleted it (local has no createdAt, model might?) - wait, if we delete, we close sidebar.
const id = model.osmId || model.id;
// 2. Check the storage service for a LIVE version of this bookmark
// This is the most critical fix: Storage is the source of truth.
// Since `this.storage.savedPlaces` is @tracked, this getter will re-compute
// whenever a bookmark is added or removed.
const saved = this.storage.findPlaceById(id);
if (saved) {
return saved;
}
// 3. If not saved, check our local "optimistic" state (from handleUpdate)
// This handles the "unsaved" state immediately after deletion before any other sync
if (this.localPlace && (this.localPlace.osmId === id || this.localPlace.id === id)) {
return this.localPlace;
}
// 4. Fallback to the route model (which might be the stale "saved" object from when the route loaded)
// If the model *has* a createdAt but we didn't find it in step 2 (storage),
// it means it was deleted. We must return a sanitized version.
if (model.createdAt) {
return {
...model,
id: undefined,
createdAt: undefined,
geohash: undefined
};
}
return model;
}

View File

@@ -30,7 +30,7 @@ const esmParserOptions = {
};
export default defineConfig([
globalIgnores(['dist/', 'coverage/', '!**/.*']),
globalIgnores(['dist/', 'coverage/', 'release/', '!**/.*']),
js.configs.recommended,
eslintConfigPrettier,
ember.configs.base,

View File

@@ -1,6 +1,6 @@
{
"name": "marco",
"version": "1.3.1",
"version": "1.4.2",
"private": true,
"description": "Small description for marco goes here",
"repository": "",

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

@@ -6,8 +6,8 @@
<meta name="description" content="">
<meta name="viewport" content="width=device-width, initial-scale=1">
<script type="module" crossorigin src="/assets/main-DQ03OXTu.js"></script>
<link rel="stylesheet" crossorigin href="/assets/main-B6U20YwQ.css">
<script type="module" crossorigin src="/assets/main-C6x36ClG.js"></script>
<link rel="stylesheet" crossorigin href="/assets/main-_X0dk-zm.css">
</head>
<body>
</body>

View File

@@ -0,0 +1,11 @@
import { module, test } from 'qunit';
import { setupTest } from 'marco/tests/helpers';
module('Unit | Route | place', function (hooks) {
setupTest(hooks);
test('it exists', function (assert) {
let route = this.owner.lookup('route:place');
assert.ok(route);
});
});