Compare commits
33 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
026d1c4712
|
|||
|
6bd55843bb
|
|||
|
33a6469a19
|
|||
|
6d7bea411a
|
|||
|
7b01bb1118
|
|||
|
84d4f9cbbf
|
|||
|
f7e7480e51
|
|||
|
6e87ef3573
|
|||
|
86b85e9a0b
|
|||
|
2a203e8e82
|
|||
|
b08dcedd13
|
|||
|
5267ffdd5c
|
|||
|
deae2260b1
|
|||
|
3c5b4d9b98
|
|||
|
b419b498da
|
|||
|
be921cf3ca
|
|||
|
99aeee51bd
|
|||
|
fa4115b714
|
|||
|
360e511849
|
|||
|
0fee9ad2dd
|
|||
|
c61c2c0e7a
|
|||
|
25f50f9091
|
|||
|
cf9139b9c1
|
|||
|
01c3b5a1ac
|
|||
|
3fcaa0bfa2
|
|||
|
0074b63ab2
|
|||
|
da3b5f2dd8
|
|||
|
26548cc97d
|
|||
|
babf2c4a89
|
|||
|
fbb90a330b
|
|||
|
15cbb3c9f3
|
|||
|
b6fd4aaea8
|
|||
|
696d4b0ae3
|
@@ -3,6 +3,7 @@
|
|||||||
|
|
||||||
# compiled output
|
# compiled output
|
||||||
/dist/
|
/dist/
|
||||||
|
/release/
|
||||||
|
|
||||||
# misc
|
# misc
|
||||||
/coverage/
|
/coverage/
|
||||||
|
|||||||
@@ -3,3 +3,4 @@
|
|||||||
|
|
||||||
# compiled output
|
# compiled output
|
||||||
/dist/
|
/dist/
|
||||||
|
/release/
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Project Status: Marco
|
# Project Status: Marco
|
||||||
|
|
||||||
**Last Updated:** Mon Jan 19 2026
|
**Last Updated:** Wed Jan 21 2026
|
||||||
|
|
||||||
## Project Context
|
## Project Context
|
||||||
|
|
||||||
@@ -19,12 +19,18 @@ We are building **Marco**, a decentralized maps application using **Ember.js** (
|
|||||||
- **Optimization:** Added **10px hit tolerance** for easier tapping on mobile devices.
|
- **Optimization:** Added **10px hit tolerance** for easier tapping on mobile devices.
|
||||||
- **Visuals:** Increased bookmark marker size (Radius 9px) and added a subtle drop shadow.
|
- **Visuals:** Increased bookmark marker size (Radius 9px) and added a subtle drop shadow.
|
||||||
- **Feedback:** Implemented a "pulse" animation (via OpenLayers Overlay) at the click location to visualize the search radius (30m/50m).
|
- **Feedback:** Implemented a "pulse" animation (via OpenLayers Overlay) at the click location to visualize the search radius (30m/50m).
|
||||||
- **Mobile UX:** 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"):**
|
- **Geolocation ("Locate Me"):**
|
||||||
- Implemented a "Locate Me" button with robust tracking logic.
|
- Implemented a "Locate Me" button with robust tracking logic.
|
||||||
- **Dynamic Zoom:** Automatically zooms to a level where the accuracy circle covers ~10% of the map (fallback logic handles missing accuracy data).
|
- **Dynamic Zoom:** Automatically zooms to a level where the accuracy circle covers ~10% of the map (fallback logic handles missing accuracy data).
|
||||||
- **Smart Pulse:** Displays a pulsing blue circle during the search phase.
|
- **Smart Pulse:** Displays a pulsing blue circle during the search phase.
|
||||||
- **Auto-Stop:** Pulse and tracking automatically stop when high accuracy (≤20m) is achieved or after a 10s timeout.
|
- **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`)
|
### 2. RemoteStorage Module (`@remotestorage/module-places`)
|
||||||
|
|
||||||
@@ -46,7 +52,11 @@ We are building **Marco**, a decentralized maps application using **Ember.js** (
|
|||||||
- `osm.js`: Fetches nearby POIs from Overpass API.
|
- `osm.js`: Fetches nearby POIs from Overpass API.
|
||||||
- **Reliability:** Implemented `fetchWithRetry` to handle HTTP 504/502/503 timeouts and 429 rate limits, in addition to network errors.
|
- **Reliability:** Implemented `fetchWithRetry` to handle HTTP 504/502/503 timeouts and 429 rate limits, in addition to network errors.
|
||||||
- **UI Components:**
|
- **UI Components:**
|
||||||
- `places-sidebar.gjs`: Displays a list of nearby POIs. Allows selecting a place to view details and saving it as a bookmark. Links to the OSM website via the node ID.
|
- `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.
|
||||||
- **Geo Utils:**
|
- **Geo Utils:**
|
||||||
- `app/utils/geo.js`: Haversine distance calculations.
|
- `app/utils/geo.js`: Haversine distance calculations.
|
||||||
- `app/utils/geohash-coverage.js`: Logic to calculate required 4-char geohash prefixes for a given bounding box.
|
- `app/utils/geohash-coverage.js`: Logic to calculate required 4-char geohash prefixes for a given bounding box.
|
||||||
@@ -54,6 +64,7 @@ We are building **Marco**, a decentralized maps application using **Ember.js** (
|
|||||||
### 4. Routing & Data Optimization
|
### 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).
|
- **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.
|
- **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`.
|
- **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`.
|
||||||
|
|
||||||
@@ -63,26 +74,25 @@ We are building **Marco**, a decentralized maps application using **Ember.js** (
|
|||||||
- **Workflow:**
|
- **Workflow:**
|
||||||
1. User pans map -> `moveend` triggers `storage.loadPlacesInBounds`.
|
1. User pans map -> `moveend` triggers `storage.loadPlacesInBounds`.
|
||||||
2. User clicks map -> "Pulse" animation -> hybrid hit detection (Visual Tile vs Overpass).
|
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.
|
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 (using normalized `osmTags`).
|
4. Sidebar displays details via `<PlaceDetails>` component (Bottom sheet on mobile).
|
||||||
5. User clicks "Save Bookmark" -> Stores JSON in RemoteStorage.
|
5. User clicks "Save Bookmark" -> Stores JSON in RemoteStorage.
|
||||||
6. RemoteStorage change event -> Debounced reload updates the map reactive-ly.
|
6. RemoteStorage change event -> Debounced reload updates the map reactive-ly.
|
||||||
|
|
||||||
## Files Currently in Focus
|
## Files Currently in Focus
|
||||||
|
|
||||||
- `app/routes/place.js`: Routing logic, ID parsing, and URL serialization.
|
- `app/styles/app.css`: Responsive sidebar styles and mobile optimizations.
|
||||||
- `app/services/osm.js`: Data fetching and normalization.
|
- `app/components/map.gjs`: Map rendering, interaction, and mobile auto-panning.
|
||||||
- `app/components/map.gjs`: Map rendering and interaction.
|
- `app/templates/application.gjs`: Root template handling place selection logic.
|
||||||
- `app/services/storage.js`: Data sync logic.
|
- `app/services/storage.js`: Data sync logic.
|
||||||
|
|
||||||
## Next Steps & Pending Tasks
|
## Next Steps & Pending Tasks
|
||||||
|
|
||||||
1. **App Header:** Implement a transparent header bar with the App Logo (left) and Login/User Info (right).
|
1. **App Header:** Implement a transparent header bar with the App Logo (left) and Login/User Info (right).
|
||||||
2. **Persist View:** Store the current map center and zoom level in `localStorage` to restore the view upon re-opening the app.
|
2. **Edit Bookmarks:** Allow users to edit the title and description of saved places.
|
||||||
3. **Edit Bookmarks:** Allow users to edit the title and description of saved places.
|
3. **Linting & Code Quality:** Fix remaining CSS errors, remove inline styles in `map.gjs`, and address unused variables/runloop usage.
|
||||||
4. **Refine UI/UX:** Further polish sidebar interactions and mobile responsiveness.
|
4. **Performance:** Monitor performance with large datasets (thousands of bookmarks).
|
||||||
5. **Performance:** Monitor performance with large datasets (thousands of bookmarks).
|
5. **Testing:** Add automated tests for the geohash coverage and retry logic.
|
||||||
6. **Testing:** Add automated tests for the geohash coverage and retry logic.
|
|
||||||
|
|
||||||
## Technical Constraints
|
## Technical Constraints
|
||||||
|
|
||||||
|
|||||||
54
app/components/icon.gjs
Normal file
54
app/components/icon.gjs
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import Component from '@glimmer/component';
|
||||||
|
import { htmlSafe } from '@ember/template';
|
||||||
|
|
||||||
|
import clock from 'feather-icons/dist/icons/clock.svg?raw';
|
||||||
|
import globe from 'feather-icons/dist/icons/globe.svg?raw';
|
||||||
|
import home from 'feather-icons/dist/icons/home.svg?raw';
|
||||||
|
import map from 'feather-icons/dist/icons/map.svg?raw';
|
||||||
|
import mapPin from 'feather-icons/dist/icons/map-pin.svg?raw';
|
||||||
|
import navigation from 'feather-icons/dist/icons/navigation.svg?raw';
|
||||||
|
import phone from 'feather-icons/dist/icons/phone.svg?raw';
|
||||||
|
import user from 'feather-icons/dist/icons/user.svg?raw';
|
||||||
|
import settings from 'feather-icons/dist/icons/settings.svg?raw';
|
||||||
|
|
||||||
|
const ICONS = {
|
||||||
|
clock,
|
||||||
|
globe,
|
||||||
|
home,
|
||||||
|
map,
|
||||||
|
mapPin,
|
||||||
|
navigation,
|
||||||
|
phone,
|
||||||
|
user,
|
||||||
|
settings
|
||||||
|
};
|
||||||
|
|
||||||
|
export default class IconComponent extends Component {
|
||||||
|
get svg() {
|
||||||
|
return ICONS[this.args.name];
|
||||||
|
}
|
||||||
|
|
||||||
|
get size() {
|
||||||
|
return this.args.size || 16;
|
||||||
|
}
|
||||||
|
|
||||||
|
get color() {
|
||||||
|
return this.args.color || '#898989';
|
||||||
|
}
|
||||||
|
|
||||||
|
get style() {
|
||||||
|
return `width:${this.size}px;height:${this.size}px;color:${this.color}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
get title() {
|
||||||
|
return this.args.title || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
<template>
|
||||||
|
{{#if this.svg}}
|
||||||
|
<span class="icon" style={{this.style}} title={{this.title}}>
|
||||||
|
{{htmlSafe this.svg}}
|
||||||
|
</span>
|
||||||
|
{{/if}}
|
||||||
|
</template>
|
||||||
|
}
|
||||||
@@ -16,15 +16,19 @@ import Geolocation from 'ol/Geolocation.js';
|
|||||||
import { Style, Circle, Fill, Stroke } from 'ol/style.js';
|
import { Style, Circle, Fill, Stroke } from 'ol/style.js';
|
||||||
import { apply } from 'ol-mapbox-style';
|
import { apply } from 'ol-mapbox-style';
|
||||||
import { getDistance } from '../utils/geo';
|
import { getDistance } from '../utils/geo';
|
||||||
|
import Icon from '../components/icon';
|
||||||
|
|
||||||
export default class MapComponent extends Component {
|
export default class MapComponent extends Component {
|
||||||
@service osm;
|
@service osm;
|
||||||
@service storage;
|
@service storage;
|
||||||
|
@service mapUi;
|
||||||
|
|
||||||
mapInstance;
|
mapInstance;
|
||||||
bookmarkSource;
|
bookmarkSource;
|
||||||
searchOverlay;
|
searchOverlay;
|
||||||
searchOverlayElement;
|
searchOverlayElement;
|
||||||
|
selectedPinOverlay;
|
||||||
|
selectedPinElement;
|
||||||
|
|
||||||
setupMap = modifier((element) => {
|
setupMap = modifier((element) => {
|
||||||
if (this.mapInstance) return;
|
if (this.mapInstance) return;
|
||||||
@@ -57,9 +61,33 @@ export default class MapComponent extends Component {
|
|||||||
zIndex: 10, // Ensure it sits above the map tiles
|
zIndex: 10, // Ensure it sits above the map tiles
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
// Default view settings
|
||||||
|
let center = [99.05738, 7.55087];
|
||||||
|
let zoom = 13.0;
|
||||||
|
|
||||||
|
// Try to restore from localStorage
|
||||||
|
try {
|
||||||
|
const storedView = localStorage.getItem('marco:map-view');
|
||||||
|
if (storedView) {
|
||||||
|
const parsed = JSON.parse(storedView);
|
||||||
|
if (
|
||||||
|
parsed.center &&
|
||||||
|
Array.isArray(parsed.center) &&
|
||||||
|
parsed.center.length === 2 &&
|
||||||
|
typeof parsed.zoom === 'number'
|
||||||
|
) {
|
||||||
|
center = parsed.center;
|
||||||
|
zoom = parsed.zoom;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Failed to restore map view:', e);
|
||||||
|
}
|
||||||
|
|
||||||
const view = new View({
|
const view = new View({
|
||||||
center: fromLonLat([99.05738, 7.55087]),
|
center: fromLonLat(center),
|
||||||
zoom: 13.0,
|
zoom: zoom,
|
||||||
projection: 'EPSG:3857',
|
projection: 'EPSG:3857',
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -67,7 +95,7 @@ export default class MapComponent extends Component {
|
|||||||
target: element,
|
target: element,
|
||||||
layers: [openfreemap, bookmarkLayer],
|
layers: [openfreemap, bookmarkLayer],
|
||||||
view: view,
|
view: view,
|
||||||
controls: defaultControls({ zoom: false, rotate: false, attribution: true }),
|
controls: defaultControls({ zoom: false, rotate: true, attribution: true }),
|
||||||
});
|
});
|
||||||
|
|
||||||
apply(this.mapInstance, 'https://tiles.openfreemap.org/styles/liberty');
|
apply(this.mapInstance, 'https://tiles.openfreemap.org/styles/liberty');
|
||||||
@@ -81,6 +109,34 @@ export default class MapComponent extends Component {
|
|||||||
});
|
});
|
||||||
this.mapInstance.addOverlay(this.searchOverlay);
|
this.mapInstance.addOverlay(this.searchOverlay);
|
||||||
|
|
||||||
|
// Selected Pin Overlay (Red Marker)
|
||||||
|
// We create the element in the template (or JS) and attach it.
|
||||||
|
// 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';
|
||||||
|
// We can't use the Glimmer <Icon> component easily inside a raw DOM element created here.
|
||||||
|
// So we'll inject the SVG string directly or mount it.
|
||||||
|
// 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';
|
||||||
|
|
||||||
|
this.selectedPinElement.appendChild(pinIcon);
|
||||||
|
this.selectedPinElement.appendChild(pinShadow);
|
||||||
|
|
||||||
|
this.selectedPinOverlay = new Overlay({
|
||||||
|
element: this.selectedPinElement,
|
||||||
|
positioning: 'bottom-center', // Important: Pin tip is at the bottom
|
||||||
|
stopEvent: false, // Let clicks pass through
|
||||||
|
});
|
||||||
|
this.mapInstance.addOverlay(this.selectedPinOverlay);
|
||||||
|
|
||||||
// Geolocation Pulse Overlay
|
// Geolocation Pulse Overlay
|
||||||
this.locationOverlayElement = document.createElement('div');
|
this.locationOverlayElement = document.createElement('div');
|
||||||
this.locationOverlayElement.className = 'search-pulse blue';
|
this.locationOverlayElement.className = 'search-pulse blue';
|
||||||
@@ -288,10 +344,129 @@ export default class MapComponent extends Component {
|
|||||||
// });
|
// });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Track the selected place from the UI Service (Router -> Map)
|
||||||
|
updateSelectedPin = modifier(() => {
|
||||||
|
const selected = this.mapUi.selectedPlace;
|
||||||
|
|
||||||
|
if (!this.selectedPinOverlay || !this.selectedPinElement) return;
|
||||||
|
|
||||||
|
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.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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
const size = this.mapInstance.getSize();
|
||||||
|
// Check if mobile (width <= 768px matches CSS)
|
||||||
|
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,
|
||||||
|
easing: (t) => t * (2 - t) // Ease-out
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Re-fetch bookmarks when the version changes (triggered by parent action or service)
|
// Re-fetch bookmarks when the version changes (triggered by parent action or service)
|
||||||
updateBookmarks = modifier(() => {
|
updateBookmarks = modifier(() => {
|
||||||
// Depend on the tracked storage.savedPlaces to automatically update when they change
|
// Depend on the tracked storage.placesInView to automatically update when they change
|
||||||
const places = this.storage.savedPlaces;
|
const places = this.storage.placesInView;
|
||||||
this.loadBookmarks(places);
|
this.loadBookmarks(places);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -301,13 +476,13 @@ export default class MapComponent extends Component {
|
|||||||
|
|
||||||
if (!places || places.length === 0) {
|
if (!places || places.length === 0) {
|
||||||
// Fallback or explicit check if we have tracked property usage?
|
// Fallback or explicit check if we have tracked property usage?
|
||||||
// The service updates 'savedPlaces'. We should probably use that if we want reactiveness.
|
// The service updates 'placesInView'. We should probably use that if we want reactiveness.
|
||||||
places = this.storage.savedPlaces;
|
places = this.storage.placesInView;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Previously: const places = await this.storage.places.getPlaces();
|
// Previously: const places = await this.storage.places.getPlaces();
|
||||||
// We no longer want to fetch everything blindly.
|
// 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();
|
this.bookmarkSource.clear();
|
||||||
|
|
||||||
@@ -340,7 +515,23 @@ export default class MapComponent extends Component {
|
|||||||
|
|
||||||
const bbox = { minLat, minLon, maxLat, maxLon };
|
const bbox = { minLat, minLon, maxLat, maxLon };
|
||||||
await this.storage.loadPlacesInBounds(bbox);
|
await this.storage.loadPlacesInBounds(bbox);
|
||||||
this.loadBookmarks(this.storage.savedPlaces);
|
this.loadBookmarks(this.storage.placesInView);
|
||||||
|
|
||||||
|
// Persist view to localStorage
|
||||||
|
try {
|
||||||
|
const view = this.mapInstance.getView();
|
||||||
|
const currentCenter = toLonLat(view.getCenter());
|
||||||
|
const currentZoom = view.getZoom();
|
||||||
|
|
||||||
|
const viewState = {
|
||||||
|
center: currentCenter,
|
||||||
|
zoom: currentZoom
|
||||||
|
};
|
||||||
|
|
||||||
|
localStorage.setItem('marco:map-view', JSON.stringify(viewState));
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Failed to save map view:', e);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
handleMapClick = async (event) => {
|
handleMapClick = async (event) => {
|
||||||
@@ -353,6 +544,8 @@ export default class MapComponent extends Component {
|
|||||||
let selectedFeatureType = null;
|
let selectedFeatureType = null;
|
||||||
|
|
||||||
if (features && features.length > 0) {
|
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'));
|
const bookmarkFeature = features.find((f) => f.get('isBookmark'));
|
||||||
if (bookmarkFeature) {
|
if (bookmarkFeature) {
|
||||||
clickedBookmark = bookmarkFeature.get('originalPlace');
|
clickedBookmark = bookmarkFeature.get('originalPlace');
|
||||||
@@ -492,6 +685,7 @@ export default class MapComponent extends Component {
|
|||||||
class="map-container"
|
class="map-container"
|
||||||
{{this.setupMap}}
|
{{this.setupMap}}
|
||||||
{{this.updateBookmarks}}
|
{{this.updateBookmarks}}
|
||||||
|
{{this.updateSelectedPin}}
|
||||||
style="position: absolute; inset: 0;"
|
style="position: absolute; inset: 0;"
|
||||||
></div>
|
></div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
219
app/components/place-details.gjs
Normal file
219
app/components/place-details.gjs
Normal file
@@ -0,0 +1,219 @@
|
|||||||
|
import Component from '@glimmer/component';
|
||||||
|
import { fn } from '@ember/helper';
|
||||||
|
import { on } from '@ember/modifier';
|
||||||
|
import capitalize from '../helpers/capitalize';
|
||||||
|
import Icon from '../components/icon';
|
||||||
|
|
||||||
|
export default class PlaceDetails extends Component {
|
||||||
|
get place() {
|
||||||
|
return this.args.place || {};
|
||||||
|
}
|
||||||
|
|
||||||
|
get tags() {
|
||||||
|
return this.place.osmTags || {};
|
||||||
|
}
|
||||||
|
|
||||||
|
get name() {
|
||||||
|
return (
|
||||||
|
this.place.title ||
|
||||||
|
this.tags.name ||
|
||||||
|
this.tags['name:en'] ||
|
||||||
|
'Unnamed Place'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
get type() {
|
||||||
|
return (
|
||||||
|
this.tags.amenity ||
|
||||||
|
this.tags.shop ||
|
||||||
|
this.tags.tourism ||
|
||||||
|
this.tags.leisure ||
|
||||||
|
this.tags.historic ||
|
||||||
|
'Point of Interest'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
get address() {
|
||||||
|
const t = this.tags;
|
||||||
|
const parts = [];
|
||||||
|
|
||||||
|
// Street + Number
|
||||||
|
if (t['addr:street']) {
|
||||||
|
let street = t['addr:street'];
|
||||||
|
if (t['addr:housenumber']) {
|
||||||
|
street += ` ${t['addr:housenumber']}`;
|
||||||
|
}
|
||||||
|
parts.push(street);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Postcode + City
|
||||||
|
if (t['addr:city']) {
|
||||||
|
let city = t['addr:city'];
|
||||||
|
if (t['addr:postcode']) {
|
||||||
|
city = `${t['addr:postcode']} ${city}`;
|
||||||
|
}
|
||||||
|
parts.push(city);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parts.length === 0) return null;
|
||||||
|
return parts.join(', ');
|
||||||
|
}
|
||||||
|
|
||||||
|
get phone() {
|
||||||
|
return this.tags.phone || this.tags['contact:phone'];
|
||||||
|
}
|
||||||
|
|
||||||
|
get website() {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
get cuisine() {
|
||||||
|
if (!this.tags.cuisine) return null;
|
||||||
|
return this.tags.cuisine
|
||||||
|
.split(';')
|
||||||
|
.map(c => capitalize.compute([c]))
|
||||||
|
.map(c => c.replace('_', ' '))
|
||||||
|
.join(', ');
|
||||||
|
}
|
||||||
|
|
||||||
|
get wikipedia() {
|
||||||
|
return this.tags.wikipedia;
|
||||||
|
}
|
||||||
|
|
||||||
|
get geoLink() {
|
||||||
|
const lat = this.place.lat;
|
||||||
|
const lon = this.place.lon;
|
||||||
|
if (!lat || !lon) return '#';
|
||||||
|
const label = encodeURIComponent(this.name);
|
||||||
|
return `geo:${lat},${lon}?q=${lat},${lon}(${label})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
get visibleGeoLink() {
|
||||||
|
const lat = this.place.lat;
|
||||||
|
const lon = this.place.lon;
|
||||||
|
if (!lat || !lon) return '';
|
||||||
|
return `${lat}, ${lon}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
get osmUrl() {
|
||||||
|
const id = this.place.osmId;
|
||||||
|
if (!id) return null;
|
||||||
|
const type = this.place.osmType || 'node';
|
||||||
|
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>
|
||||||
|
<p class="place-type">
|
||||||
|
{{this.type}}
|
||||||
|
</p>
|
||||||
|
{{#if this.place.description}}
|
||||||
|
<p class="place-description">
|
||||||
|
{{this.place.description}}
|
||||||
|
</p>
|
||||||
|
{{/if}}
|
||||||
|
|
||||||
|
<div class="actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class={{if this.place.createdAt "btn-secondary" "btn-primary"}}
|
||||||
|
{{on "click" (fn @onToggleSave this.place)}}
|
||||||
|
>
|
||||||
|
{{if this.place.createdAt "Saved ✓" "Save"}}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="meta-info">
|
||||||
|
|
||||||
|
{{#if this.cuisine}}
|
||||||
|
<p>
|
||||||
|
<strong>Cuisine:</strong>
|
||||||
|
{{this.cuisine}}
|
||||||
|
</p>
|
||||||
|
{{/if}}
|
||||||
|
|
||||||
|
{{#if this.openingHours}}
|
||||||
|
<p class="content-with-icon">
|
||||||
|
<Icon @name="clock" @title="Opening hours" />
|
||||||
|
<span>{{this.openingHours}}</span>
|
||||||
|
</p>
|
||||||
|
{{/if}}
|
||||||
|
|
||||||
|
{{#if this.phone}}
|
||||||
|
<p class="content-with-icon">
|
||||||
|
<Icon @name="phone" @title="Phone" />
|
||||||
|
<span><a href="tel:{{this.phone}}">{{this.phone}}</a></span>
|
||||||
|
</p>
|
||||||
|
{{/if}}
|
||||||
|
|
||||||
|
{{#if this.website}}
|
||||||
|
<p class="content-with-icon">
|
||||||
|
<Icon @name="globe" @title="Website" />
|
||||||
|
<span><a href={{this.website}} target="_blank" rel="noopener noreferrer">{{this.websiteDomain}}</a></span>
|
||||||
|
</p>
|
||||||
|
{{/if}}
|
||||||
|
|
||||||
|
{{#if this.wikipedia}}
|
||||||
|
<p>
|
||||||
|
<strong>Wikipedia:</strong>
|
||||||
|
<a href="https://wikipedia.org/wiki/{{this.wikipedia}}" target="_blank" rel="noopener noreferrer">Article</a>
|
||||||
|
</p>
|
||||||
|
{{/if}}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
<div class="meta-info">
|
||||||
|
|
||||||
|
{{#if this.address}}
|
||||||
|
<p class="content-with-icon">
|
||||||
|
<Icon @name="home" @title="Address" />
|
||||||
|
<span>{{this.address}}</span>
|
||||||
|
</p>
|
||||||
|
{{/if}}
|
||||||
|
|
||||||
|
<p class="content-with-icon">
|
||||||
|
<Icon @name="mapPin" @title="Geo link" />
|
||||||
|
<span>
|
||||||
|
<a href={{this.geoLink}} target="_blank" rel="noopener noreferrer">
|
||||||
|
{{this.visibleGeoLink}}
|
||||||
|
</a>
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{{#if this.osmUrl}}
|
||||||
|
<p class="content-with-icon">
|
||||||
|
<Icon @name="map" @title="OSM ID" />
|
||||||
|
<span>
|
||||||
|
<a href={{this.osmUrl}} target="_blank" rel="noopener noreferrer">
|
||||||
|
OpenStreetMap
|
||||||
|
</a>
|
||||||
|
</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>
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import { action } from '@ember/object';
|
|||||||
import { on } from '@ember/modifier';
|
import { on } from '@ember/modifier';
|
||||||
import { fn } from '@ember/helper';
|
import { fn } from '@ember/helper';
|
||||||
import or from 'ember-truth-helpers/helpers/or';
|
import or from 'ember-truth-helpers/helpers/or';
|
||||||
|
import PlaceDetails from './place-details';
|
||||||
|
|
||||||
export default class PlacesSidebar extends Component {
|
export default class PlacesSidebar extends Component {
|
||||||
@service storage;
|
@service storage;
|
||||||
@@ -30,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
|
@action
|
||||||
async toggleSave(place) {
|
async toggleSave(place) {
|
||||||
if (!place) return;
|
if (!place) return;
|
||||||
|
|
||||||
if (place.createdAt) {
|
if (place.createdAt) {
|
||||||
// It's a saved bookmark -> Delete it
|
|
||||||
if (confirm(`Delete "${place.title}"?`)) {
|
if (confirm(`Delete "${place.title}"?`)) {
|
||||||
try {
|
try {
|
||||||
if (place.id && place.geohash) {
|
await this.storage.removePlace(place);
|
||||||
await this.storage.places.remove(place.id, place.geohash);
|
console.log('Place deleted:', place.title);
|
||||||
console.log('Place deleted:', place.title);
|
|
||||||
|
|
||||||
// Notify parent to refresh map bookmarks
|
// Notify parent to refresh map bookmarks
|
||||||
if (this.args.onBookmarkChange) {
|
if (this.args.onBookmarkChange) {
|
||||||
this.args.onBookmarkChange();
|
this.args.onBookmarkChange();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update selection to the new saved place object
|
// Update selection to the new saved place object
|
||||||
// This updates the local UI state immediately without a route refresh
|
// This updates the local UI state immediately without a route refresh
|
||||||
if (this.args.onUpdate) {
|
if (this.args.onUpdate) {
|
||||||
// When deleting, we revert to a "fresh" object or just close.
|
// 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,
|
// Since we close the sidebar below, we might not strictly need to update local state,
|
||||||
// but it's good practice.
|
// but it's good practice.
|
||||||
// Reconstruct the "original" place without ID/Geohash/CreatedAt
|
// Reconstruct the "original" place without ID/Geohash/CreatedAt
|
||||||
const freshPlace = {
|
const freshPlace = {
|
||||||
...place,
|
...place,
|
||||||
id: undefined,
|
id: undefined,
|
||||||
geohash: undefined,
|
geohash: undefined,
|
||||||
createdAt: undefined
|
createdAt: undefined
|
||||||
};
|
};
|
||||||
this.args.onUpdate(freshPlace);
|
this.args.onUpdate(freshPlace);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Also fire onSelect if it exists (for list view)
|
// Also fire onSelect if it exists (for list view)
|
||||||
if (this.args.onSelect) {
|
if (this.args.onSelect) {
|
||||||
// Similar logic for select if needed, but we usually close.
|
// Similar logic for select if needed, but we usually close.
|
||||||
this.args.onSelect(null);
|
this.args.onSelect(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Close sidebar after delete
|
// Close sidebar after delete
|
||||||
if (this.args.onClose) {
|
if (this.args.onClose) {
|
||||||
this.args.onClose();
|
this.args.onClose();
|
||||||
}
|
|
||||||
} else {
|
|
||||||
alert('Cannot delete: Missing ID or Geohash');
|
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Failed to delete:', e);
|
console.error('Failed to delete:', e);
|
||||||
@@ -114,7 +91,7 @@ export default class PlacesSidebar extends Component {
|
|||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const savedPlace = await this.storage.places.store(placeData);
|
const savedPlace = await this.storage.storePlace(placeData);
|
||||||
console.log('Place saved:', placeData.title);
|
console.log('Place saved:', placeData.title);
|
||||||
|
|
||||||
// Notify parent to refresh map bookmarks
|
// Notify parent to refresh map bookmarks
|
||||||
@@ -147,7 +124,6 @@ export default class PlacesSidebar extends Component {
|
|||||||
class="back-btn"
|
class="back-btn"
|
||||||
{{on "click" this.clearSelection}}
|
{{on "click" this.clearSelection}}
|
||||||
>←</button>
|
>←</button>
|
||||||
<h2>Details</h2>
|
|
||||||
{{else}}
|
{{else}}
|
||||||
<h2>Nearby Places</h2>
|
<h2>Nearby Places</h2>
|
||||||
{{/if}}
|
{{/if}}
|
||||||
@@ -160,77 +136,10 @@ export default class PlacesSidebar extends Component {
|
|||||||
|
|
||||||
<div class="sidebar-content">
|
<div class="sidebar-content">
|
||||||
{{#if @selectedPlace}}
|
{{#if @selectedPlace}}
|
||||||
<div class="place-details">
|
<PlaceDetails
|
||||||
<h3>{{or
|
@place={{@selectedPlace}}
|
||||||
@selectedPlace.title
|
@onToggleSave={{this.toggleSave}}
|
||||||
@selectedPlace.osmTags.name
|
/>
|
||||||
@selectedPlace.osmTags.name:en
|
|
||||||
"Unnamed Place"
|
|
||||||
}}</h3>
|
|
||||||
<p class="place-meta">
|
|
||||||
{{or
|
|
||||||
@selectedPlace.osmTags.amenity
|
|
||||||
@selectedPlace.osmTags.shop
|
|
||||||
@selectedPlace.osmTags.tourism
|
|
||||||
@selectedPlace.osmTags.leisure
|
|
||||||
@selectedPlace.osmTags.historic
|
|
||||||
}}
|
|
||||||
{{#if @selectedPlace.description}}
|
|
||||||
{{@selectedPlace.description}}
|
|
||||||
{{/if}}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{{#if (or @selectedPlace.url @selectedPlace.osmTags.website)}}
|
|
||||||
<p><a
|
|
||||||
href={{or @selectedPlace.url @selectedPlace.osmTags.website}}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>Website</a></p>
|
|
||||||
{{/if}}
|
|
||||||
|
|
||||||
{{#if @selectedPlace.osmTags.opening_hours}}
|
|
||||||
<p><strong>Open:</strong>
|
|
||||||
{{@selectedPlace.osmTags.opening_hours}}</p>
|
|
||||||
{{/if}}
|
|
||||||
|
|
||||||
<div class="actions">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class={{if
|
|
||||||
@selectedPlace.createdAt
|
|
||||||
"btn-secondary"
|
|
||||||
"btn-primary"
|
|
||||||
}}
|
|
||||||
{{on "click" (fn this.toggleSave @selectedPlace)}}
|
|
||||||
>
|
|
||||||
{{if @selectedPlace.createdAt "Saved ✓" "Save"}}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="meta-info">
|
|
||||||
{{#if (or @selectedPlace.osmId @selectedPlace.id)}}
|
|
||||||
<p>
|
|
||||||
<a
|
|
||||||
href={{this.geoLink}}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>{{this.visibleGeoLink}}</a></p>
|
|
||||||
<p><small>OSM ID:
|
|
||||||
<a
|
|
||||||
href="https://www.openstreetmap.org/{{if
|
|
||||||
@selectedPlace.osmType
|
|
||||||
@selectedPlace.osmType
|
|
||||||
(if @selectedPlace.osmType @selectedPlace.osmType 'node')
|
|
||||||
}}/{{or @selectedPlace.osmId @selectedPlace.id}}"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>{{or
|
|
||||||
@selectedPlace.osmId
|
|
||||||
@selectedPlace.id
|
|
||||||
}}</a></small></p>
|
|
||||||
{{/if}}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{{else}}
|
{{else}}
|
||||||
{{#if @places}}
|
{{#if @places}}
|
||||||
<ul class="places-list">
|
<ul class="places-list">
|
||||||
|
|||||||
8
app/helpers/capitalize.js
Normal file
8
app/helpers/capitalize.js
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
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);
|
||||||
@@ -4,35 +4,56 @@ import { service } from '@ember/service';
|
|||||||
export default class PlaceRoute extends Route {
|
export default class PlaceRoute extends Route {
|
||||||
@service storage;
|
@service storage;
|
||||||
@service osm;
|
@service osm;
|
||||||
|
@service mapUi;
|
||||||
|
|
||||||
async model(params) {
|
async model(params) {
|
||||||
const id = params.place_id;
|
const id = params.place_id;
|
||||||
|
|
||||||
// Check for explicit OSM prefixes
|
|
||||||
if (id.startsWith('osm:node:') || id.startsWith('osm:way:')) {
|
if (id.startsWith('osm:node:') || id.startsWith('osm:way:')) {
|
||||||
const [, type, osmId] = id.split(':');
|
const [, type, osmId] = id.split(':');
|
||||||
console.log(`Fetching explicit OSM ${type}:`, osmId);
|
console.log(`Fetching explicit OSM ${type}:`, osmId);
|
||||||
return this.loadOsmPlace(osmId, type);
|
return this.loadOsmPlace(osmId, type);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1. Try to find in local bookmarks
|
await this.waitForSync();
|
||||||
// We rely on the service maintaining the list
|
|
||||||
let bookmark = this.storage.findPlaceById(id);
|
|
||||||
|
|
||||||
// If not found instantly, maybe wait for storage ready?
|
let bookmark = this.storage.findPlaceById(id);
|
||||||
// For now assuming storage is reasonably fast or "ready" has fired.
|
|
||||||
// If we land here directly on refresh, "savedPlaces" might be empty initially.
|
|
||||||
// We could retry or wait, but simpler to fall back to OSM for now.
|
|
||||||
// Ideally, we await `storage.loadAllPlaces()` promise if it's pending.
|
|
||||||
|
|
||||||
if (bookmark) {
|
if (bookmark) {
|
||||||
console.log('Found in bookmarks:', bookmark.title);
|
console.log('Found in bookmarks:', bookmark.title);
|
||||||
return bookmark;
|
return bookmark;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Fallback: Fetch from OSM (assuming generic ID or old format)
|
console.warn('Not in bookmarks:', id);
|
||||||
console.log('Not in bookmarks, fetching from OSM:', id);
|
return null;
|
||||||
return this.loadOsmPlace(id);
|
}
|
||||||
|
|
||||||
|
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) {
|
||||||
|
// Notify the Map UI to show the pin
|
||||||
|
if (model) {
|
||||||
|
this.mapUi.selectPlace(model);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
deactivate() {
|
||||||
|
// Clear the pin when leaving the route
|
||||||
|
this.mapUi.clearSelection();
|
||||||
}
|
}
|
||||||
|
|
||||||
async loadOsmPlace(id, type = null) {
|
async loadOsmPlace(id, type = null) {
|
||||||
|
|||||||
14
app/services/map-ui.js
Normal file
14
app/services/map-ui.js
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import Service from '@ember/service';
|
||||||
|
import { tracked } from '@glimmer/tracking';
|
||||||
|
|
||||||
|
export default class MapUiService extends Service {
|
||||||
|
@tracked selectedPlace = null;
|
||||||
|
|
||||||
|
selectPlace(place) {
|
||||||
|
this.selectedPlace = place;
|
||||||
|
}
|
||||||
|
|
||||||
|
clearSelection() {
|
||||||
|
this.selectedPlace = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,7 +23,8 @@ export default class OsmService extends Service {
|
|||||||
out center;
|
out center;
|
||||||
`.trim();
|
`.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
|
query
|
||||||
)}`;
|
)}`;
|
||||||
|
|
||||||
|
|||||||
@@ -8,10 +8,12 @@ import Geohash from 'latlon-geohash';
|
|||||||
|
|
||||||
export default class StorageService extends Service {
|
export default class StorageService extends Service {
|
||||||
rs;
|
rs;
|
||||||
|
@tracked placesInView = [];
|
||||||
@tracked savedPlaces = [];
|
@tracked savedPlaces = [];
|
||||||
@tracked loadedPrefixes = [];
|
@tracked loadedPrefixes = [];
|
||||||
@tracked currentBbox = null;
|
@tracked currentBbox = null;
|
||||||
@tracked version = 0; // Shared version tracker for bookmarks
|
@tracked version = 0; // Shared version tracker for bookmarks
|
||||||
|
@tracked initialSyncDone = false;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super(...arguments);
|
super(...arguments);
|
||||||
@@ -31,14 +33,63 @@ export default class StorageService extends Service {
|
|||||||
// widget.attach();
|
// widget.attach();
|
||||||
|
|
||||||
this.rs.on('ready', () => {
|
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) => {
|
this.rs.scope('/places/').on('change', (event) => {
|
||||||
|
// console.debug(event);
|
||||||
|
this.handlePlaceChange(event);
|
||||||
debounce(this, this.reloadCurrentView, 200);
|
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() {
|
get places() {
|
||||||
return this.rs.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
|
// Identify existing places that belong to the reloaded prefixes and remove them
|
||||||
const prefixSet = new Set(prefixes);
|
const prefixSet = new Set(prefixes);
|
||||||
|
|
||||||
const keptPlaces = this.savedPlaces.filter((place) => {
|
const keptPlaces = this.placesInView.filter((place) => {
|
||||||
if (!place.lat || !place.lon) return false;
|
if (!place.lat || !place.lon) return false;
|
||||||
try {
|
try {
|
||||||
// Calculate 4-char geohash for the existing place
|
// 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)
|
// Merge the kept places (from other areas) with the fresh places (from these areas)
|
||||||
this.savedPlaces = [...keptPlaces, ...places];
|
this.placesInView = [...keptPlaces, ...places];
|
||||||
} else {
|
} else {
|
||||||
// Full reload
|
// Full reload
|
||||||
this.savedPlaces = places;
|
this.placesInView = places;
|
||||||
}
|
}
|
||||||
} else {
|
} 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) {
|
} catch (e) {
|
||||||
console.error('Failed to load places:', e);
|
console.error('Failed to load places:', e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
findPlaceById(id) {
|
findPlaceById(id) {
|
||||||
// Search by internal ID first
|
if (!id) return undefined;
|
||||||
let place = this.savedPlaces.find((p) => p.id === id);
|
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;
|
if (place) return place;
|
||||||
|
|
||||||
// Then search by OSM ID
|
// 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;
|
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,
|
html,
|
||||||
body {
|
body {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
overscroll-behavior: none; /* Prevent pull-to-refresh on mobile */
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
@@ -37,6 +38,7 @@ body {
|
|||||||
bottom: 0;
|
bottom: 0;
|
||||||
width: 300px;
|
width: 300px;
|
||||||
background: white;
|
background: white;
|
||||||
|
color: #333;
|
||||||
z-index: 2000;
|
z-index: 2000;
|
||||||
box-shadow: 2px 0 5px rgb(0 0 0 / 10%);
|
box-shadow: 2px 0 5px rgb(0 0 0 / 10%);
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -56,6 +58,10 @@ body {
|
|||||||
font-size: 1.2rem;
|
font-size: 1.2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sidebar-content {
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
.close-btn {
|
.close-btn {
|
||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
@@ -73,18 +79,26 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.place-details {
|
.place-details {
|
||||||
padding: 0.5rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.place-details h3 {
|
.place-details h3 {
|
||||||
|
font-size: 1.2rem;
|
||||||
margin-top: 0;
|
margin-top: 0;
|
||||||
margin-bottom: 0.5rem;
|
margin-bottom: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.place-meta {
|
.place-details .place-type {
|
||||||
color: #666;
|
color: #666;
|
||||||
|
font-size: 0.9rem;
|
||||||
text-transform: capitalize;
|
text-transform: capitalize;
|
||||||
margin-bottom: 1rem;
|
margin: 0 0 1rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.place-details .place-description {
|
||||||
|
}
|
||||||
|
|
||||||
|
.place-details .actions {
|
||||||
|
padding-bottom: 0.3rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-primary {
|
.btn-primary {
|
||||||
@@ -133,15 +147,40 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.place-type {
|
.place-type {
|
||||||
font-size: 0.85rem;
|
|
||||||
color: #666;
|
color: #666;
|
||||||
|
font-size: 0.85rem;
|
||||||
text-transform: capitalize;
|
text-transform: capitalize;
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty-state {
|
.meta-info {
|
||||||
text-align: center;
|
font-size: 0.9rem;
|
||||||
color: #666;
|
text-align: left;
|
||||||
margin-top: 2rem;
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
word-break: break-word; /* Prevent long URLs from breaking layout */
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-info strong {
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-info a {
|
||||||
|
color: #007bff;
|
||||||
|
text-decoration: none;
|
||||||
|
padding-bottom: 4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-info a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Map Search Pulse Animation */
|
/* Map Search Pulse Animation */
|
||||||
@@ -187,3 +226,113 @@ body {
|
|||||||
.ol-touch .ol-control.ol-locate {
|
.ol-touch .ol-control.ol-locate {
|
||||||
top: 5.5em; /* Adjust for touch devices where controls might be larger */
|
top: 5.5em; /* Adjust for touch devices where controls might be larger */
|
||||||
}
|
}
|
||||||
|
|
||||||
|
span.icon {
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon svg {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
stroke: currentColor;
|
||||||
|
fill: none;
|
||||||
|
stroke-width: 2;
|
||||||
|
stroke-linecap: round;
|
||||||
|
stroke-linejoin: round;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-with-icon {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Selected Pin Animation */
|
||||||
|
.selected-pin-container {
|
||||||
|
position: absolute;
|
||||||
|
/* Center the bottom tip of the pin at the coordinate */
|
||||||
|
transform: translate(-50%, -100%);
|
||||||
|
pointer-events: none; /* Let clicks pass through to the map features below if needed */
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selected-pin-container.active {
|
||||||
|
display: block;
|
||||||
|
animation: dropIn 0.5s cubic-bezier(0.175, 0.885, 0.32, 1.275) forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
.selected-pin {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
color: #ea4335; /* Google Red */
|
||||||
|
filter: drop-shadow(0 4px 6px rgba(0, 0, 0, 0.3));
|
||||||
|
}
|
||||||
|
|
||||||
|
.selected-pin svg {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
fill: #ea4335;
|
||||||
|
stroke: #b31412; /* Darker red stroke */
|
||||||
|
stroke-width: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Optional: Small dot at the bottom to ground it */
|
||||||
|
.selected-pin-shadow {
|
||||||
|
width: 10px;
|
||||||
|
height: 4px;
|
||||||
|
background: rgba(0, 0, 0, 0.3);
|
||||||
|
border-radius: 50%;
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
z-index: -1;
|
||||||
|
opacity: 0;
|
||||||
|
animation: shadowFade 0.5s 0.2s forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes dropIn {
|
||||||
|
0% {
|
||||||
|
transform: translate(-50%, -200%) scale(0);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
60% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: translate(-50%, -100%) scale(1);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes shadowFade {
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.sidebar {
|
||||||
|
top: auto;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 50vh;
|
||||||
|
box-shadow: 0 -2px 10px rgba(0, 0, 0, 0.1);
|
||||||
|
border-top-left-radius: 16px;
|
||||||
|
border-top-right-radius: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -28,15 +28,26 @@ export default class ApplicationComponent extends Component {
|
|||||||
|
|
||||||
@action
|
@action
|
||||||
showPlaces(places, selectedPlace = null) {
|
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 we have a specific place, transition to the route
|
||||||
if (selectedPlace) {
|
if (resolvedSelected) {
|
||||||
// Pass the FULL object model to avoid re-fetching!
|
// Pass the FULL object model to avoid re-fetching!
|
||||||
// The Route's serialize() hook handles URL generation.
|
// The Route's serialize() hook handles URL generation.
|
||||||
this.router.transitionTo('place', selectedPlace);
|
this.router.transitionTo('place', resolvedSelected);
|
||||||
this.nearbyPlaces = null; // Clear list when selecting specific
|
this.nearbyPlaces = null; // Clear list when selecting specific
|
||||||
} else if (places && places.length > 0) {
|
} else if (resolvedPlaces && resolvedPlaces.length > 0) {
|
||||||
// Show list case
|
// Show list case
|
||||||
this.nearbyPlaces = places;
|
this.nearbyPlaces = resolvedPlaces;
|
||||||
this.router.transitionTo('index');
|
this.router.transitionTo('index');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,20 +23,37 @@ export default class PlaceTemplate extends Component {
|
|||||||
// Let's use a modifier or just sync it.
|
// Let's use a modifier or just sync it.
|
||||||
|
|
||||||
get place() {
|
get place() {
|
||||||
// If we have a manually updated place (from save), use it.
|
// 1. Resolve the ID from the model (OSM ID or internal ID)
|
||||||
// Otherwise use the route model.
|
|
||||||
// We need to ensure we reset `localPlace` when navigating to a NEW place.
|
|
||||||
// Comparing IDs is a safe bet.
|
|
||||||
|
|
||||||
const model = this.args.model;
|
const model = this.args.model;
|
||||||
if (
|
const id = model.osmId || model.id;
|
||||||
this.localPlace &&
|
|
||||||
(this.localPlace.id === model.id || this.localPlace.osmId === model.osmId)
|
// 2. Check the storage service for a LIVE version of this bookmark
|
||||||
) {
|
// This is the most critical fix: Storage is the source of truth.
|
||||||
// If the local place is "richer" (has createdAt), prefer it.
|
// Since `this.storage.savedPlaces` is @tracked, this getter will re-compute
|
||||||
if (this.localPlace.createdAt && !model.createdAt) return this.localPlace;
|
// whenever a bookmark is added or removed.
|
||||||
// If we deleted it (local has no createdAt, model might?) - wait, if we delete, we close sidebar.
|
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;
|
return model;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ export default {
|
|||||||
[
|
[
|
||||||
'babel-plugin-ember-template-compilation',
|
'babel-plugin-ember-template-compilation',
|
||||||
{
|
{
|
||||||
compilerPath: 'ember-source/dist/ember-template-compiler.js',
|
compilerPath: 'ember-source/ember-template-compiler/index.js',
|
||||||
transforms: [...macros.templateMacros],
|
transforms: [...macros.templateMacros],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
0
dist/@embroider/virtual/vendor.css
vendored
0
dist/@embroider/virtual/vendor.css
vendored
1
dist/@embroider/virtual/vendor.js
vendored
1
dist/@embroider/virtual/vendor.js
vendored
@@ -1 +0,0 @@
|
|||||||
var runningTests=false;
|
|
||||||
15
dist/index.html
vendored
15
dist/index.html
vendored
@@ -1,15 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html>
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<title>Marco</title>
|
|
||||||
<meta name="description" content="">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
||||||
|
|
||||||
<script type="module" crossorigin src="/assets/main-91CAyURz.js"></script>
|
|
||||||
<link rel="modulepreload" crossorigin href="/assets/app-Bg0kM_Gw.js">
|
|
||||||
<link rel="stylesheet" crossorigin href="/assets/app-Dxork-AG.css">
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
3
dist/robots.txt
vendored
3
dist/robots.txt
vendored
@@ -1,3 +0,0 @@
|
|||||||
# https://www.robotstxt.org/
|
|
||||||
User-agent: *
|
|
||||||
Disallow:
|
|
||||||
@@ -30,7 +30,7 @@ const esmParserOptions = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default defineConfig([
|
export default defineConfig([
|
||||||
globalIgnores(['dist/', 'coverage/', '!**/.*']),
|
globalIgnores(['dist/', 'coverage/', 'release/', '!**/.*']),
|
||||||
js.configs.recommended,
|
js.configs.recommended,
|
||||||
eslintConfigPrettier,
|
eslintConfigPrettier,
|
||||||
ember.configs.base,
|
ember.configs.base,
|
||||||
|
|||||||
19
package.json
19
package.json
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "marco",
|
"name": "marco",
|
||||||
"version": "1.0.0",
|
"version": "1.4.2",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "Small description for marco goes here",
|
"description": "Small description for marco goes here",
|
||||||
"repository": "",
|
"repository": "",
|
||||||
@@ -46,6 +46,7 @@
|
|||||||
"@embroider/vite": "^1.5.0",
|
"@embroider/vite": "^1.5.0",
|
||||||
"@eslint/js": "^9.39.2",
|
"@eslint/js": "^9.39.2",
|
||||||
"@glimmer/component": "^2.0.0",
|
"@glimmer/component": "^2.0.0",
|
||||||
|
"@remotestorage/module-places": "link:vendor/remotestorage-module-places",
|
||||||
"@rollup/plugin-babel": "^6.1.0",
|
"@rollup/plugin-babel": "^6.1.0",
|
||||||
"@warp-drive/core": "~5.8.0",
|
"@warp-drive/core": "~5.8.0",
|
||||||
"@warp-drive/ember": "~5.8.0",
|
"@warp-drive/ember": "~5.8.0",
|
||||||
@@ -62,6 +63,7 @@
|
|||||||
"ember-resolver": "^13.1.1",
|
"ember-resolver": "^13.1.1",
|
||||||
"ember-source": "~6.11.0-alpha.6",
|
"ember-source": "~6.11.0-alpha.6",
|
||||||
"ember-template-lint": "^7.9.3",
|
"ember-template-lint": "^7.9.3",
|
||||||
|
"ember-truth-helpers": "^5.0.0",
|
||||||
"ember-welcome-page": "^8.0.4",
|
"ember-welcome-page": "^8.0.4",
|
||||||
"eslint": "^9.39.2",
|
"eslint": "^9.39.2",
|
||||||
"eslint-config-prettier": "^10.1.8",
|
"eslint-config-prettier": "^10.1.8",
|
||||||
@@ -69,11 +71,17 @@
|
|||||||
"eslint-plugin-n": "^17.23.1",
|
"eslint-plugin-n": "^17.23.1",
|
||||||
"eslint-plugin-qunit": "^8.2.5",
|
"eslint-plugin-qunit": "^8.2.5",
|
||||||
"eslint-plugin-warp-drive": "^5.8.0",
|
"eslint-plugin-warp-drive": "^5.8.0",
|
||||||
|
"feather-icons": "^4.29.2",
|
||||||
"globals": "^16.5.0",
|
"globals": "^16.5.0",
|
||||||
|
"latlon-geohash": "^2.0.0",
|
||||||
|
"ol": "^10.7.0",
|
||||||
|
"ol-mapbox-style": "^13.2.0",
|
||||||
"prettier": "^3.7.4",
|
"prettier": "^3.7.4",
|
||||||
"prettier-plugin-ember-template-tag": "^2.1.2",
|
"prettier-plugin-ember-template-tag": "^2.1.2",
|
||||||
"qunit": "^2.25.0",
|
"qunit": "^2.25.0",
|
||||||
"qunit-dom": "^3.5.0",
|
"qunit-dom": "^3.5.0",
|
||||||
|
"remotestorage-widget": "^1.8.0",
|
||||||
|
"remotestoragejs": "2.0.0-beta.8",
|
||||||
"sinon": "^21.0.1",
|
"sinon": "^21.0.1",
|
||||||
"stylelint": "^16.26.1",
|
"stylelint": "^16.26.1",
|
||||||
"stylelint-config-standard": "^38.0.0",
|
"stylelint-config-standard": "^38.0.0",
|
||||||
@@ -85,14 +93,5 @@
|
|||||||
},
|
},
|
||||||
"ember": {
|
"ember": {
|
||||||
"edition": "octane"
|
"edition": "octane"
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"@remotestorage/module-places": "link:vendor/remotestorage-module-places",
|
|
||||||
"ember-truth-helpers": "^5.0.0",
|
|
||||||
"latlon-geohash": "^2.0.0",
|
|
||||||
"ol": "^10.7.0",
|
|
||||||
"ol-mapbox-style": "^13.2.0",
|
|
||||||
"remotestorage-widget": "^1.8.0",
|
|
||||||
"remotestoragejs": "2.0.0-beta.8"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
64
pnpm-lock.yaml
generated
64
pnpm-lock.yaml
generated
@@ -7,28 +7,6 @@ settings:
|
|||||||
importers:
|
importers:
|
||||||
|
|
||||||
.:
|
.:
|
||||||
dependencies:
|
|
||||||
'@remotestorage/module-places':
|
|
||||||
specifier: link:vendor/remotestorage-module-places
|
|
||||||
version: link:vendor/remotestorage-module-places
|
|
||||||
ember-truth-helpers:
|
|
||||||
specifier: ^5.0.0
|
|
||||||
version: 5.0.0
|
|
||||||
latlon-geohash:
|
|
||||||
specifier: ^2.0.0
|
|
||||||
version: 2.0.0
|
|
||||||
ol:
|
|
||||||
specifier: ^10.7.0
|
|
||||||
version: 10.7.0
|
|
||||||
ol-mapbox-style:
|
|
||||||
specifier: ^13.2.0
|
|
||||||
version: 13.2.0(ol@10.7.0)
|
|
||||||
remotestorage-widget:
|
|
||||||
specifier: ^1.8.0
|
|
||||||
version: 1.8.0
|
|
||||||
remotestoragejs:
|
|
||||||
specifier: 2.0.0-beta.8
|
|
||||||
version: 2.0.0-beta.8
|
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@babel/core':
|
'@babel/core':
|
||||||
specifier: ^7.28.5
|
specifier: ^7.28.5
|
||||||
@@ -69,6 +47,9 @@ importers:
|
|||||||
'@glimmer/component':
|
'@glimmer/component':
|
||||||
specifier: ^2.0.0
|
specifier: ^2.0.0
|
||||||
version: 2.0.0
|
version: 2.0.0
|
||||||
|
'@remotestorage/module-places':
|
||||||
|
specifier: link:vendor/remotestorage-module-places
|
||||||
|
version: link:vendor/remotestorage-module-places
|
||||||
'@rollup/plugin-babel':
|
'@rollup/plugin-babel':
|
||||||
specifier: ^6.1.0
|
specifier: ^6.1.0
|
||||||
version: 6.1.0(@babel/core@7.28.6)(rollup@4.55.1)
|
version: 6.1.0(@babel/core@7.28.6)(rollup@4.55.1)
|
||||||
@@ -117,6 +98,9 @@ importers:
|
|||||||
ember-template-lint:
|
ember-template-lint:
|
||||||
specifier: ^7.9.3
|
specifier: ^7.9.3
|
||||||
version: 7.9.3
|
version: 7.9.3
|
||||||
|
ember-truth-helpers:
|
||||||
|
specifier: ^5.0.0
|
||||||
|
version: 5.0.0
|
||||||
ember-welcome-page:
|
ember-welcome-page:
|
||||||
specifier: ^8.0.4
|
specifier: ^8.0.4
|
||||||
version: 8.0.5(@babel/core@7.28.6)
|
version: 8.0.5(@babel/core@7.28.6)
|
||||||
@@ -138,9 +122,21 @@ importers:
|
|||||||
eslint-plugin-warp-drive:
|
eslint-plugin-warp-drive:
|
||||||
specifier: ^5.8.0
|
specifier: ^5.8.0
|
||||||
version: 5.8.1(@babel/core@7.28.6)
|
version: 5.8.1(@babel/core@7.28.6)
|
||||||
|
feather-icons:
|
||||||
|
specifier: ^4.29.2
|
||||||
|
version: 4.29.2
|
||||||
globals:
|
globals:
|
||||||
specifier: ^16.5.0
|
specifier: ^16.5.0
|
||||||
version: 16.5.0
|
version: 16.5.0
|
||||||
|
latlon-geohash:
|
||||||
|
specifier: ^2.0.0
|
||||||
|
version: 2.0.0
|
||||||
|
ol:
|
||||||
|
specifier: ^10.7.0
|
||||||
|
version: 10.7.0
|
||||||
|
ol-mapbox-style:
|
||||||
|
specifier: ^13.2.0
|
||||||
|
version: 13.2.0(ol@10.7.0)
|
||||||
prettier:
|
prettier:
|
||||||
specifier: ^3.7.4
|
specifier: ^3.7.4
|
||||||
version: 3.7.4
|
version: 3.7.4
|
||||||
@@ -153,6 +149,12 @@ importers:
|
|||||||
qunit-dom:
|
qunit-dom:
|
||||||
specifier: ^3.5.0
|
specifier: ^3.5.0
|
||||||
version: 3.5.0
|
version: 3.5.0
|
||||||
|
remotestorage-widget:
|
||||||
|
specifier: ^1.8.0
|
||||||
|
version: 1.8.0
|
||||||
|
remotestoragejs:
|
||||||
|
specifier: 2.0.0-beta.8
|
||||||
|
version: 2.0.0-beta.8
|
||||||
sinon:
|
sinon:
|
||||||
specifier: ^21.0.1
|
specifier: ^21.0.1
|
||||||
version: 21.0.1
|
version: 21.0.1
|
||||||
@@ -1794,6 +1796,9 @@ packages:
|
|||||||
charm@1.0.2:
|
charm@1.0.2:
|
||||||
resolution: {integrity: sha512-wqW3VdPnlSWT4eRiYX+hcs+C6ViBPUWk1qTCd+37qw9kEm/a5n2qcyQDMBWvSYKN/ctqZzeXNQaeBjOetJJUkw==}
|
resolution: {integrity: sha512-wqW3VdPnlSWT4eRiYX+hcs+C6ViBPUWk1qTCd+37qw9kEm/a5n2qcyQDMBWvSYKN/ctqZzeXNQaeBjOetJJUkw==}
|
||||||
|
|
||||||
|
classnames@2.5.1:
|
||||||
|
resolution: {integrity: sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==}
|
||||||
|
|
||||||
clean-up-path@1.0.0:
|
clean-up-path@1.0.0:
|
||||||
resolution: {integrity: sha512-PHGlEF0Z6976qQyN6gM7kKH6EH0RdfZcc8V+QhFe36eRxV0SMH5OUBZG7Bxa9YcreNzyNbK63cGiZxdSZgosRw==}
|
resolution: {integrity: sha512-PHGlEF0Z6976qQyN6gM7kKH6EH0RdfZcc8V+QhFe36eRxV0SMH5OUBZG7Bxa9YcreNzyNbK63cGiZxdSZgosRw==}
|
||||||
|
|
||||||
@@ -2062,6 +2067,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-Kb2wC0fvsWfQrgk8HU5lW6U/Lcs8+9aaYcy4ZFc6DDlo4nZ7n70dEgE5rtR0oG6ufKDUnrwfWL1mXR5ljDatrQ==}
|
resolution: {integrity: sha512-Kb2wC0fvsWfQrgk8HU5lW6U/Lcs8+9aaYcy4ZFc6DDlo4nZ7n70dEgE5rtR0oG6ufKDUnrwfWL1mXR5ljDatrQ==}
|
||||||
deprecated: core-js@<3.23.3 is no longer maintained and not recommended for usage due to the number of issues. Because of the V8 engine whims, feature detection in old core-js versions could cause a slowdown up to 100x even if nothing is polyfilled. Some versions have web compatibility issues. Please, upgrade your dependencies to the actual version of core-js.
|
deprecated: core-js@<3.23.3 is no longer maintained and not recommended for usage due to the number of issues. Because of the V8 engine whims, feature detection in old core-js versions could cause a slowdown up to 100x even if nothing is polyfilled. Some versions have web compatibility issues. Please, upgrade your dependencies to the actual version of core-js.
|
||||||
|
|
||||||
|
core-js@3.47.0:
|
||||||
|
resolution: {integrity: sha512-c3Q2VVkGAUyupsjRnaNX6u8Dq2vAdzm9iuPj5FW0fRxzlxgq9Q39MDq10IvmQSpLgHQNyQzQmOo6bgGHmH3NNg==}
|
||||||
|
|
||||||
core-util-is@1.0.3:
|
core-util-is@1.0.3:
|
||||||
resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==}
|
resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==}
|
||||||
|
|
||||||
@@ -2544,6 +2552,9 @@ packages:
|
|||||||
picomatch:
|
picomatch:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
feather-icons@4.29.2:
|
||||||
|
resolution: {integrity: sha512-0TaCFTnBTVCz6U+baY2UJNKne5ifGh7sMG4ZC2LoBWCZdIyPa+y6UiR4lEYGws1JOFWdee8KAsAIvu0VcXqiqA==}
|
||||||
|
|
||||||
file-entry-cache@11.1.1:
|
file-entry-cache@11.1.1:
|
||||||
resolution: {integrity: sha512-TPVFSDE7q91Dlk1xpFLvFllf8r0HyOMOlnWy7Z2HBku5H3KhIeOGInexrIeg2D64DosVB/JXkrrk6N/7Wriq4A==}
|
resolution: {integrity: sha512-TPVFSDE7q91Dlk1xpFLvFllf8r0HyOMOlnWy7Z2HBku5H3KhIeOGInexrIeg2D64DosVB/JXkrrk6N/7Wriq4A==}
|
||||||
|
|
||||||
@@ -6397,6 +6408,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
inherits: 2.0.4
|
inherits: 2.0.4
|
||||||
|
|
||||||
|
classnames@2.5.1: {}
|
||||||
|
|
||||||
clean-up-path@1.0.0: {}
|
clean-up-path@1.0.0: {}
|
||||||
|
|
||||||
cliui@8.0.1:
|
cliui@8.0.1:
|
||||||
@@ -6497,6 +6510,8 @@ snapshots:
|
|||||||
|
|
||||||
core-js@2.6.12: {}
|
core-js@2.6.12: {}
|
||||||
|
|
||||||
|
core-js@3.47.0: {}
|
||||||
|
|
||||||
core-util-is@1.0.3: {}
|
core-util-is@1.0.3: {}
|
||||||
|
|
||||||
cors@2.8.5:
|
cors@2.8.5:
|
||||||
@@ -7206,6 +7221,11 @@ snapshots:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
picomatch: 4.0.3
|
picomatch: 4.0.3
|
||||||
|
|
||||||
|
feather-icons@4.29.2:
|
||||||
|
dependencies:
|
||||||
|
classnames: 2.5.1
|
||||||
|
core-js: 3.47.0
|
||||||
|
|
||||||
file-entry-cache@11.1.1:
|
file-entry-cache@11.1.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
flat-cache: 6.1.19
|
flat-cache: 6.1.19
|
||||||
|
|||||||
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
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="description" content="">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
|
||||||
<script type="module" crossorigin src="/assets/main-PEcndiCZ.js"></script>
|
<script type="module" crossorigin src="/assets/main-C6x36ClG.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/main-Dxork-AG.css">
|
<link rel="stylesheet" crossorigin href="/assets/main-_X0dk-zm.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
</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