Compare commits
31 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
|
@@ -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;
|
||||||
@@ -91,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');
|
||||||
@@ -105,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';
|
||||||
@@ -312,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);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -325,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();
|
||||||
|
|
||||||
@@ -364,7 +515,7 @@ 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
|
// Persist view to localStorage
|
||||||
try {
|
try {
|
||||||
@@ -393,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');
|
||||||
@@ -532,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,35 +31,14 @@ 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
|
||||||
@@ -92,9 +72,6 @@ export default class PlacesSidebar extends Component {
|
|||||||
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);
|
||||||
alert('Failed to delete: ' + e.message);
|
alert('Failed to delete: ' + e.message);
|
||||||
@@ -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.1.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
|
||||||
|
|||||||
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="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-BBfpqXeX.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