Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
026d1c4712
|
|||
|
6bd55843bb
|
|||
|
33a6469a19
|
|||
|
6d7bea411a
|
|||
|
7b01bb1118
|
|||
|
84d4f9cbbf
|
|||
|
f7e7480e51
|
|||
|
6e87ef3573
|
|||
|
86b85e9a0b
|
|||
|
2a203e8e82
|
|||
|
b08dcedd13
|
|||
|
5267ffdd5c
|
|||
|
deae2260b1
|
|||
|
3c5b4d9b98
|
|||
|
b419b498da
|
|||
|
be921cf3ca
|
@@ -3,6 +3,7 @@
|
||||
|
||||
# compiled output
|
||||
/dist/
|
||||
/release/
|
||||
|
||||
# misc
|
||||
/coverage/
|
||||
|
||||
@@ -3,3 +3,4 @@
|
||||
|
||||
# compiled output
|
||||
/dist/
|
||||
/release/
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@ export default class IconComponent extends Component {
|
||||
}
|
||||
|
||||
get color() {
|
||||
return this.args.color || '#888';
|
||||
return this.args.color || '#898989';
|
||||
}
|
||||
|
||||
get style() {
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
)}`;
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@ const esmParserOptions = {
|
||||
};
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist/', 'coverage/', '!**/.*']),
|
||||
globalIgnores(['dist/', 'coverage/', 'release/', '!**/.*']),
|
||||
js.configs.recommended,
|
||||
eslintConfigPrettier,
|
||||
ember.configs.base,
|
||||
|
||||
@@ -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
2
release/assets/main-C6x36ClG.js
Normal file
2
release/assets/main-C6x36ClG.js
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
1
release/assets/main-_X0dk-zm.css
Normal file
1
release/assets/main-_X0dk-zm.css
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -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>
|
||||
|
||||
11
tests/unit/routes/place-test.js
Normal file
11
tests/unit/routes/place-test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user