Refactor to use routes, make POIs linkable
This commit is contained in:
@@ -77,14 +77,17 @@ export default class MapComponent extends Component {
|
|||||||
// Listen to changes in the /places/ scope
|
// Listen to changes in the /places/ scope
|
||||||
// keeping this as a backup or for future real-time sync support
|
// keeping this as a backup or for future real-time sync support
|
||||||
this.storage.rs.scope('/places/').on('change', (event) => {
|
this.storage.rs.scope('/places/').on('change', (event) => {
|
||||||
console.log('RemoteStorage change detected:', event);
|
console.log('RemoteStorage change detected:', event);
|
||||||
// this.loadBookmarks(); // Disabling auto-update for now per instructions, using explicit version action instead
|
// this.loadBookmarks(); // Disabling auto-update for now per instructions, using explicit version action instead
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Re-fetch bookmarks when the version changes (triggered by parent action)
|
// Re-fetch bookmarks when the version changes (triggered by parent action or service)
|
||||||
updateBookmarks = modifier((element, [version]) => {
|
updateBookmarks = modifier(() => {
|
||||||
this.loadBookmarks();
|
// Depend on the tracked storage.version
|
||||||
|
if (this.storage.version >= 0) {
|
||||||
|
this.loadBookmarks();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
async loadBookmarks() {
|
async loadBookmarks() {
|
||||||
@@ -94,21 +97,21 @@ export default class MapComponent extends Component {
|
|||||||
this.bookmarkSource.clear();
|
this.bookmarkSource.clear();
|
||||||
|
|
||||||
if (places && Array.isArray(places)) {
|
if (places && Array.isArray(places)) {
|
||||||
places.forEach(place => {
|
places.forEach((place) => {
|
||||||
if (place.lat && place.lon) {
|
if (place.lat && place.lon) {
|
||||||
const feature = new Feature({
|
const feature = new Feature({
|
||||||
geometry: new Point(fromLonLat([place.lon, place.lat])),
|
geometry: new Point(fromLonLat([place.lon, place.lat])),
|
||||||
name: place.title,
|
name: place.title,
|
||||||
id: place.id,
|
id: place.id,
|
||||||
isBookmark: true, // Marker property to distinguish
|
isBookmark: true, // Marker property to distinguish
|
||||||
originalPlace: place
|
originalPlace: place,
|
||||||
});
|
});
|
||||||
this.bookmarkSource.addFeature(feature);
|
this.bookmarkSource.addFeature(feature);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Failed to load bookmarks:", e);
|
console.error('Failed to load bookmarks:', e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -120,43 +123,46 @@ export default class MapComponent extends Component {
|
|||||||
let selectedFeatureType = null;
|
let selectedFeatureType = null;
|
||||||
|
|
||||||
if (features && features.length > 0) {
|
if (features && features.length > 0) {
|
||||||
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');
|
||||||
}
|
}
|
||||||
// Also get visual props for standard map click logic later
|
// Also get visual props for standard map click logic later
|
||||||
const props = features[0].getProperties();
|
const props = features[0].getProperties();
|
||||||
if (props.name) {
|
if (props.name) {
|
||||||
selectedFeatureName = props.name;
|
selectedFeatureName = props.name;
|
||||||
selectedFeatureType = props.class || props.subclass;
|
selectedFeatureType = props.class || props.subclass;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Special handling when sidebar is OPEN
|
// Special handling when sidebar is OPEN
|
||||||
if (this.args.isSidebarOpen) {
|
if (this.args.isSidebarOpen) {
|
||||||
// If it's a bookmark, we allow "switching" to it even if sidebar is open
|
// If it's a bookmark, we allow "switching" to it even if sidebar is open
|
||||||
if (clickedBookmark) {
|
if (clickedBookmark) {
|
||||||
console.log("Clicked bookmark while sidebar open (switching):", clickedBookmark);
|
console.log(
|
||||||
if (this.args.onPlacesFound) {
|
'Clicked bookmark while sidebar open (switching):',
|
||||||
this.args.onPlacesFound([], clickedBookmark);
|
clickedBookmark
|
||||||
}
|
);
|
||||||
return;
|
if (this.args.onPlacesFound) {
|
||||||
}
|
this.args.onPlacesFound([], clickedBookmark);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Otherwise (empty map or non-bookmark feature), close the sidebar
|
// Otherwise (empty map or non-bookmark feature), close the sidebar
|
||||||
if (this.args.onOutsideClick) {
|
if (this.args.onOutsideClick) {
|
||||||
this.args.onOutsideClick();
|
this.args.onOutsideClick();
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Normal behavior (sidebar is closed)
|
// Normal behavior (sidebar is closed)
|
||||||
if (clickedBookmark) {
|
if (clickedBookmark) {
|
||||||
console.log("Clicked bookmark:", clickedBookmark);
|
console.log('Clicked bookmark:', clickedBookmark);
|
||||||
if (this.args.onPlacesFound) {
|
if (this.args.onPlacesFound) {
|
||||||
this.args.onPlacesFound([], clickedBookmark);
|
this.args.onPlacesFound([], clickedBookmark);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const coords = toLonLat(event.coordinate);
|
const coords = toLonLat(event.coordinate);
|
||||||
@@ -170,46 +176,63 @@ export default class MapComponent extends Component {
|
|||||||
let pois = await this.osm.getNearbyPois(lat, lon, searchRadius);
|
let pois = await this.osm.getNearbyPois(lat, lon, searchRadius);
|
||||||
|
|
||||||
// Sort by distance from click
|
// Sort by distance from click
|
||||||
pois = pois.map(p => {
|
pois = pois
|
||||||
// Use center lat/lon for ways/relations if available, else lat/lon
|
.map((p) => {
|
||||||
const pLat = p.lat || p.center?.lat;
|
// Use center lat/lon for ways/relations if available, else lat/lon
|
||||||
const pLon = p.lon || p.center?.lon;
|
const pLat = p.lat || p.center?.lat;
|
||||||
return {
|
const pLon = p.lon || p.center?.lon;
|
||||||
...p,
|
return {
|
||||||
_distance: (pLat && pLon) ? getDistance(lat, lon, pLat, pLon) : 9999
|
...p,
|
||||||
};
|
_distance: pLat && pLon ? getDistance(lat, lon, pLat, pLon) : 9999,
|
||||||
}).sort((a, b) => a._distance - b._distance);
|
};
|
||||||
|
})
|
||||||
|
.sort((a, b) => a._distance - b._distance);
|
||||||
|
|
||||||
let matchedPlace = null;
|
let matchedPlace = null;
|
||||||
|
|
||||||
if (selectedFeatureName && pois.length > 0) {
|
if (selectedFeatureName && pois.length > 0) {
|
||||||
// Heuristic:
|
// Heuristic:
|
||||||
// 1. Exact Name Match
|
// 1. Exact Name Match
|
||||||
matchedPlace = pois.find(p => p.tags && (p.tags.name === selectedFeatureName || p.tags['name:en'] === selectedFeatureName));
|
matchedPlace = pois.find(
|
||||||
|
(p) =>
|
||||||
|
p.tags &&
|
||||||
|
(p.tags.name === selectedFeatureName ||
|
||||||
|
p.tags['name:en'] === selectedFeatureName)
|
||||||
|
);
|
||||||
|
|
||||||
// 2. If no exact match, look for VERY close (<=20m) and matching type
|
// 2. If no exact match, look for VERY close (<=20m) and matching type
|
||||||
if (!matchedPlace) {
|
if (!matchedPlace) {
|
||||||
const topCandidate = pois[0];
|
const topCandidate = pois[0];
|
||||||
if (topCandidate._distance <= 20) {
|
if (topCandidate._distance <= 20) {
|
||||||
// Check type compatibility if available
|
// Check type compatibility if available
|
||||||
// (visual tile 'class' is often 'cafe', osm tag is 'amenity'='cafe')
|
// (visual tile 'class' is often 'cafe', osm tag is 'amenity'='cafe')
|
||||||
const pType = topCandidate.tags.amenity || topCandidate.tags.shop || topCandidate.tags.tourism;
|
const pType =
|
||||||
if (selectedFeatureType && pType && (selectedFeatureType === pType || pType.includes(selectedFeatureType))) {
|
topCandidate.tags.amenity ||
|
||||||
console.log("Heuristic match found (distance + type):", topCandidate);
|
topCandidate.tags.shop ||
|
||||||
matchedPlace = topCandidate;
|
topCandidate.tags.tourism;
|
||||||
} else if (topCandidate._distance <= 10) {
|
if (
|
||||||
// Even without type match, if it's super close (<=10m), it's likely the one.
|
selectedFeatureType &&
|
||||||
console.log("Heuristic match found (proximity):", topCandidate);
|
pType &&
|
||||||
matchedPlace = topCandidate;
|
(selectedFeatureType === pType ||
|
||||||
}
|
pType.includes(selectedFeatureType))
|
||||||
}
|
) {
|
||||||
|
console.log(
|
||||||
|
'Heuristic match found (distance + type):',
|
||||||
|
topCandidate
|
||||||
|
);
|
||||||
|
matchedPlace = topCandidate;
|
||||||
|
} else if (topCandidate._distance <= 10) {
|
||||||
|
// Even without type match, if it's super close (<=10m), it's likely the one.
|
||||||
|
console.log('Heuristic match found (proximity):', topCandidate);
|
||||||
|
matchedPlace = topCandidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.args.onPlacesFound) {
|
if (this.args.onPlacesFound) {
|
||||||
this.args.onPlacesFound(pois, matchedPlace);
|
this.args.onPlacesFound(pois, matchedPlace);
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to fetch POIs:', error);
|
console.error('Failed to fetch POIs:', error);
|
||||||
}
|
}
|
||||||
@@ -218,7 +241,7 @@ export default class MapComponent extends Component {
|
|||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
{{this.setupMap}}
|
{{this.setupMap}}
|
||||||
{{this.updateBookmarks @bookmarksVersion}}
|
{{this.updateBookmarks}}
|
||||||
style="position: absolute; inset: 0;"
|
style="position: absolute; inset: 0;"
|
||||||
></div>
|
></div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import Component from '@glimmer/component';
|
import Component from '@glimmer/component';
|
||||||
import { service } from '@ember/service';
|
import { service } from '@ember/service';
|
||||||
import { action } from '@ember/object';
|
import { action } from '@ember/object';
|
||||||
import { tracked } from '@glimmer/tracking';
|
|
||||||
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';
|
||||||
@@ -22,7 +21,7 @@ export default class PlacesSidebar extends Component {
|
|||||||
if (this.args.onSelect) {
|
if (this.args.onSelect) {
|
||||||
this.args.onSelect(null);
|
this.args.onSelect(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback logic: if no list available, close sidebar
|
// Fallback logic: if no list available, close sidebar
|
||||||
if (!this.args.places || this.args.places.length === 0) {
|
if (!this.args.places || this.args.places.length === 0) {
|
||||||
if (this.args.onClose) {
|
if (this.args.onClose) {
|
||||||
@@ -47,13 +46,35 @@ export default class PlacesSidebar extends Component {
|
|||||||
if (this.args.onBookmarkChange) {
|
if (this.args.onBookmarkChange) {
|
||||||
this.args.onBookmarkChange();
|
this.args.onBookmarkChange();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Update selection to the new saved place object
|
||||||
|
// This updates the local UI state immediately without a route refresh
|
||||||
|
if (this.args.onUpdate) {
|
||||||
|
// When deleting, we revert to a "fresh" object or just close.
|
||||||
|
// Since we close the sidebar below, we might not strictly need to update local state,
|
||||||
|
// but it's good practice.
|
||||||
|
// Reconstruct the "original" place without ID/Geohash/CreatedAt
|
||||||
|
const freshPlace = {
|
||||||
|
...place,
|
||||||
|
id: undefined,
|
||||||
|
geohash: undefined,
|
||||||
|
createdAt: undefined
|
||||||
|
};
|
||||||
|
this.args.onUpdate(freshPlace);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also fire onSelect if it exists (for list view)
|
||||||
|
if (this.args.onSelect) {
|
||||||
|
// Similar logic for select if needed, but we usually close.
|
||||||
|
this.args.onSelect(null);
|
||||||
|
}
|
||||||
|
|
||||||
// Close sidebar after delete
|
// Close sidebar after delete
|
||||||
if (this.args.onClose) {
|
if (this.args.onClose) {
|
||||||
this.args.onClose();
|
this.args.onClose();
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
alert('Cannot delete: Missing ID or Geohash');
|
alert('Cannot delete: Missing ID or Geohash');
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Failed to delete:', e);
|
console.error('Failed to delete:', e);
|
||||||
@@ -66,18 +87,23 @@ export default class PlacesSidebar extends Component {
|
|||||||
title: place.tags.name || place.tags['name:en'] || 'Untitled Place',
|
title: place.tags.name || place.tags['name:en'] || 'Untitled Place',
|
||||||
lat: place.lat,
|
lat: place.lat,
|
||||||
lon: place.lon,
|
lon: place.lon,
|
||||||
tags: [],
|
tags: [],
|
||||||
url: place.tags.website,
|
url: place.tags.website,
|
||||||
osmId: String(place.id),
|
osmId: String(place.osmId || place.id), // Ensure we grab osmId if available, or fallback to id
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const savedPlace = await this.storage.places.store(placeData);
|
const savedPlace = await this.storage.places.store(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
|
||||||
if (this.args.onBookmarkChange) {
|
if (this.args.onBookmarkChange) {
|
||||||
this.args.onBookmarkChange();
|
this.args.onBookmarkChange();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update selection to the new saved place object
|
||||||
|
if (this.args.onUpdate) {
|
||||||
|
this.args.onUpdate(savedPlace);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update selection to the new saved place object
|
// Update selection to the new saved place object
|
||||||
@@ -95,44 +121,86 @@ export default class PlacesSidebar extends Component {
|
|||||||
<div class="sidebar">
|
<div class="sidebar">
|
||||||
<div class="sidebar-header">
|
<div class="sidebar-header">
|
||||||
{{#if @selectedPlace}}
|
{{#if @selectedPlace}}
|
||||||
<button type="button" class="back-btn" {{on "click" this.clearSelection}}>←</button>
|
<button
|
||||||
<h2>Details</h2>
|
type="button"
|
||||||
|
class="back-btn"
|
||||||
|
{{on "click" this.clearSelection}}
|
||||||
|
>←</button>
|
||||||
|
<h2>Details</h2>
|
||||||
{{else}}
|
{{else}}
|
||||||
<h2>Nearby Places</h2>
|
<h2>Nearby Places</h2>
|
||||||
{{/if}}
|
{{/if}}
|
||||||
<button type="button" class="close-btn" {{on "click" @onClose}}>×</button>
|
<button
|
||||||
|
type="button"
|
||||||
|
class="close-btn"
|
||||||
|
{{on "click" @onClose}}
|
||||||
|
>×</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="sidebar-content">
|
<div class="sidebar-content">
|
||||||
{{#if @selectedPlace}}
|
{{#if @selectedPlace}}
|
||||||
<div class="place-details">
|
<div class="place-details">
|
||||||
<h3>{{or @selectedPlace.title @selectedPlace.tags.name @selectedPlace.tags.name:en "Unnamed Place"}}</h3>
|
<h3>{{or
|
||||||
|
@selectedPlace.title
|
||||||
|
@selectedPlace.tags.name
|
||||||
|
@selectedPlace.tags.name:en
|
||||||
|
"Unnamed Place"
|
||||||
|
}}</h3>
|
||||||
<p class="place-meta">
|
<p class="place-meta">
|
||||||
{{#if @selectedPlace.tags.amenity}}
|
{{#if @selectedPlace.tags.amenity}}
|
||||||
{{or @selectedPlace.tags.amenity @selectedPlace.tags.shop @selectedPlace.tags.tourism}}
|
{{or
|
||||||
|
@selectedPlace.tags.amenity
|
||||||
|
@selectedPlace.tags.shop
|
||||||
|
@selectedPlace.tags.tourism
|
||||||
|
}}
|
||||||
{{else}}
|
{{else}}
|
||||||
{{@selectedPlace.description}}
|
{{@selectedPlace.description}}
|
||||||
{{/if}}
|
{{/if}}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{{#if (or @selectedPlace.url @selectedPlace.tags.website)}}
|
{{#if (or @selectedPlace.url @selectedPlace.tags.website)}}
|
||||||
<p><a href={{or @selectedPlace.url @selectedPlace.tags.website}} target="_blank" rel="noopener noreferrer">Website</a></p>
|
<p><a
|
||||||
|
href={{or @selectedPlace.url @selectedPlace.tags.website}}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>Website</a></p>
|
||||||
{{/if}}
|
{{/if}}
|
||||||
|
|
||||||
{{#if @selectedPlace.tags.opening_hours}}
|
{{#if @selectedPlace.tags.opening_hours}}
|
||||||
<p><strong>Open:</strong> {{@selectedPlace.tags.opening_hours}}</p>
|
<p><strong>Open:</strong>
|
||||||
|
{{@selectedPlace.tags.opening_hours}}</p>
|
||||||
{{/if}}
|
{{/if}}
|
||||||
|
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<button type="button" class={{if @selectedPlace.createdAt "btn-secondary" "btn-primary"}} {{on "click" (fn this.toggleSave @selectedPlace)}}>
|
<button
|
||||||
{{if @selectedPlace.createdAt "Saved ✓" "Save"}}
|
type="button"
|
||||||
</button>
|
class={{if
|
||||||
|
@selectedPlace.createdAt
|
||||||
|
"btn-secondary"
|
||||||
|
"btn-primary"
|
||||||
|
}}
|
||||||
|
{{on "click" (fn this.toggleSave @selectedPlace)}}
|
||||||
|
>
|
||||||
|
{{if @selectedPlace.createdAt "Saved ✓" "Save"}}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="meta-info">
|
<div class="meta-info">
|
||||||
{{#if (or @selectedPlace.osmId @selectedPlace.id)}}
|
{{#if (or @selectedPlace.osmId @selectedPlace.id)}}
|
||||||
<p><small>OSM ID: <a href="https://www.openstreetmap.org/{{if @selectedPlace.type @selectedPlace.type 'node'}}/{{or @selectedPlace.osmId @selectedPlace.id}}" target="_blank" rel="noopener noreferrer">{{or @selectedPlace.osmId @selectedPlace.id}}</a></small></p>
|
<p><small>OSM ID:
|
||||||
{{/if}}
|
<a
|
||||||
|
href="https://www.openstreetmap.org/{{if
|
||||||
|
@selectedPlace.type
|
||||||
|
@selectedPlace.type
|
||||||
|
'node'
|
||||||
|
}}/{{or @selectedPlace.osmId @selectedPlace.id}}"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>{{or
|
||||||
|
@selectedPlace.osmId
|
||||||
|
@selectedPlace.id
|
||||||
|
}}</a></small></p>
|
||||||
|
{{/if}}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{{else}}
|
{{else}}
|
||||||
@@ -140,9 +208,22 @@ export default class PlacesSidebar extends Component {
|
|||||||
<ul class="places-list">
|
<ul class="places-list">
|
||||||
{{#each @places as |place|}}
|
{{#each @places as |place|}}
|
||||||
<li>
|
<li>
|
||||||
<button type="button" class="place-item" {{on "click" (fn this.selectPlace place)}}>
|
<button
|
||||||
<div class="place-name">{{or place.tags.name place.tags.name:en "Unnamed Place"}}</div>
|
type="button"
|
||||||
<div class="place-type">{{or place.tags.amenity place.tags.shop place.tags.tourism "Point of Interest"}}</div>
|
class="place-item"
|
||||||
|
{{on "click" (fn this.selectPlace place)}}
|
||||||
|
>
|
||||||
|
<div class="place-name">{{or
|
||||||
|
place.tags.name
|
||||||
|
place.tags.name:en
|
||||||
|
"Unnamed Place"
|
||||||
|
}}</div>
|
||||||
|
<div class="place-type">{{or
|
||||||
|
place.tags.amenity
|
||||||
|
place.tags.shop
|
||||||
|
place.tags.tourism
|
||||||
|
"Point of Interest"
|
||||||
|
}}</div>
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
{{/each}}
|
{{/each}}
|
||||||
|
|||||||
@@ -6,4 +6,6 @@ export default class Router extends EmberRouter {
|
|||||||
rootURL = config.rootURL;
|
rootURL = config.rootURL;
|
||||||
}
|
}
|
||||||
|
|
||||||
Router.map(function () {});
|
Router.map(function () {
|
||||||
|
this.route('place', { path: '/place/:place_id' });
|
||||||
|
});
|
||||||
|
|||||||
7
app/routes/application.js
Normal file
7
app/routes/application.js
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import Route from '@ember/routing/route';
|
||||||
|
import { service } from '@ember/service';
|
||||||
|
|
||||||
|
export default class ApplicationRoute extends Route {
|
||||||
|
@service osm;
|
||||||
|
@service storage;
|
||||||
|
}
|
||||||
49
app/routes/place.js
Normal file
49
app/routes/place.js
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import Route from '@ember/routing/route';
|
||||||
|
import { service } from '@ember/service';
|
||||||
|
|
||||||
|
export default class PlaceRoute extends Route {
|
||||||
|
@service storage;
|
||||||
|
@service osm;
|
||||||
|
|
||||||
|
async model(params) {
|
||||||
|
const id = params.place_id;
|
||||||
|
|
||||||
|
// 1. Try to find in local bookmarks
|
||||||
|
// We rely on the service maintaining the list
|
||||||
|
let bookmark = this.storage.findPlaceById(id);
|
||||||
|
|
||||||
|
// If not found instantly, maybe wait for storage ready?
|
||||||
|
// 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) {
|
||||||
|
console.log('Found in bookmarks:', bookmark.title);
|
||||||
|
return bookmark;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Fallback: Fetch from OSM
|
||||||
|
console.log('Not in bookmarks, fetching from OSM:', id);
|
||||||
|
try {
|
||||||
|
const poi = await this.osm.getPoiById(id);
|
||||||
|
if (poi) {
|
||||||
|
// Map to our Place schema so the sidebar understands it
|
||||||
|
return {
|
||||||
|
title: poi.tags.name || poi.tags['name:en'] || 'Untitled Place',
|
||||||
|
lat: poi.lat || poi.center?.lat,
|
||||||
|
lon: poi.lon || poi.center?.lon,
|
||||||
|
tags: poi.tags, // raw tags
|
||||||
|
url: poi.tags.website,
|
||||||
|
osmId: String(poi.id),
|
||||||
|
description: poi.tags.description, // ensure description maps
|
||||||
|
// No ID/Geohash/CreatedAt means it's not saved
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to fetch POI', e);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,4 +22,31 @@ out center;
|
|||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
return data.elements;
|
return data.elements;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getPoiById(id) {
|
||||||
|
// Assuming 'id' is just the numeric ID.
|
||||||
|
// Overpass needs type(id). But we might not know the type (node, way, relation).
|
||||||
|
// We can query all types for this ID.
|
||||||
|
// However, typical usage often passes just the numeric ID.
|
||||||
|
// A query for just ID(numeric) is tricky without type.
|
||||||
|
// Let's assume 'node' first or try to query all three types by ID.
|
||||||
|
|
||||||
|
const query = `
|
||||||
|
[out:json][timeout:25];
|
||||||
|
(
|
||||||
|
node(${id});
|
||||||
|
way(${id});
|
||||||
|
relation(${id});
|
||||||
|
);
|
||||||
|
out center;
|
||||||
|
`.trim();
|
||||||
|
|
||||||
|
const url = `https://overpass-api.de/api/interpreter?data=${encodeURIComponent(
|
||||||
|
query
|
||||||
|
)}`;
|
||||||
|
const res = await fetch(url);
|
||||||
|
if (!res.ok) throw new Error('Overpass request failed');
|
||||||
|
const data = await res.json();
|
||||||
|
return data.elements[0]; // Return the first match
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,9 +2,12 @@ import Service from '@ember/service';
|
|||||||
import RemoteStorage from 'remotestoragejs';
|
import RemoteStorage from 'remotestoragejs';
|
||||||
import Places from '@remotestorage/module-places';
|
import Places from '@remotestorage/module-places';
|
||||||
import Widget from 'remotestorage-widget';
|
import Widget from 'remotestorage-widget';
|
||||||
|
import { tracked } from '@glimmer/tracking';
|
||||||
|
|
||||||
export default class StorageService extends Service {
|
export default class StorageService extends Service {
|
||||||
rs;
|
rs;
|
||||||
|
@tracked savedPlaces = [];
|
||||||
|
@tracked version = 0; // Shared version tracker for bookmarks
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super(...arguments);
|
super(...arguments);
|
||||||
@@ -21,9 +24,46 @@ export default class StorageService extends Service {
|
|||||||
|
|
||||||
// const widget = new Widget(this.rs);
|
// const widget = new Widget(this.rs);
|
||||||
// widget.attach();
|
// widget.attach();
|
||||||
|
|
||||||
|
this.rs.on('ready', () => {
|
||||||
|
this.loadAllPlaces();
|
||||||
|
});
|
||||||
|
|
||||||
|
this.rs.scope('/places/').on('change', () => {
|
||||||
|
this.loadAllPlaces();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
get places() {
|
get places() {
|
||||||
return this.rs.places;
|
return this.rs.places;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
notifyChange() {
|
||||||
|
this.version++;
|
||||||
|
this.loadAllPlaces();
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadAllPlaces() {
|
||||||
|
try {
|
||||||
|
const places = await this.rs.places.listAll();
|
||||||
|
if (places && Array.isArray(places)) {
|
||||||
|
this.savedPlaces = places;
|
||||||
|
} else {
|
||||||
|
this.savedPlaces = [];
|
||||||
|
}
|
||||||
|
console.log('Loaded saved places:', this.savedPlaces.length);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to load places:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
findPlaceById(id) {
|
||||||
|
// Search by internal ID first
|
||||||
|
let place = this.savedPlaces.find((p) => p.id === id);
|
||||||
|
if (place) return place;
|
||||||
|
|
||||||
|
// Then search by OSM ID
|
||||||
|
place = this.savedPlaces.find((p) => p.osmId === id);
|
||||||
|
return place;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ body {
|
|||||||
width: 300px;
|
width: 300px;
|
||||||
background: white;
|
background: white;
|
||||||
z-index: 2000;
|
z-index: 2000;
|
||||||
box-shadow: 2px 0 5px rgba(0, 0, 0, 0.1);
|
box-shadow: 2px 0 5px rgb(0 0 0 / 10%);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,14 +5,19 @@ import PlacesSidebar from '#components/places-sidebar';
|
|||||||
import { service } from '@ember/service';
|
import { service } from '@ember/service';
|
||||||
import { tracked } from '@glimmer/tracking';
|
import { tracked } from '@glimmer/tracking';
|
||||||
import { action } from '@ember/object';
|
import { action } from '@ember/object';
|
||||||
|
import { eq } from 'ember-truth-helpers';
|
||||||
|
import { and } from 'ember-truth-helpers';
|
||||||
|
|
||||||
export default class ApplicationComponent extends Component {
|
export default class ApplicationComponent extends Component {
|
||||||
@service storage;
|
@service storage;
|
||||||
|
@service router;
|
||||||
|
|
||||||
@tracked nearbyPlaces = null;
|
@tracked nearbyPlaces = null;
|
||||||
@tracked selectedPlace = null;
|
// @tracked bookmarksVersion = 0; // Moved to storage service
|
||||||
@tracked isSidebarOpen = false;
|
|
||||||
@tracked bookmarksVersion = 0;
|
get isSidebarOpen() {
|
||||||
|
return !!this.nearbyPlaces || this.router.currentRouteName === 'place';
|
||||||
|
}
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super(...arguments);
|
super(...arguments);
|
||||||
@@ -23,45 +28,56 @@ export default class ApplicationComponent extends Component {
|
|||||||
|
|
||||||
@action
|
@action
|
||||||
showPlaces(places, selectedPlace = null) {
|
showPlaces(places, selectedPlace = null) {
|
||||||
this.nearbyPlaces = places;
|
// If we have a specific place, transition to the route
|
||||||
this.selectedPlace = selectedPlace;
|
if (selectedPlace) {
|
||||||
this.isSidebarOpen = true;
|
// Use ID if available, or osmId
|
||||||
|
const id = selectedPlace.id || selectedPlace.osmId;
|
||||||
|
if (id) {
|
||||||
|
this.router.transitionTo('place', id);
|
||||||
|
}
|
||||||
|
this.nearbyPlaces = null; // Clear list when selecting specific
|
||||||
|
} else if (places && places.length > 0) {
|
||||||
|
// Show list case
|
||||||
|
this.nearbyPlaces = places;
|
||||||
|
this.router.transitionTo('index');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@action
|
@action
|
||||||
selectPlace(place) {
|
selectFromList(place) {
|
||||||
this.selectedPlace = place;
|
if (place) {
|
||||||
|
const id = place.id || place.osmId;
|
||||||
|
if (id) {
|
||||||
|
this.router.transitionTo('place', id);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@action
|
@action
|
||||||
closeSidebar() {
|
closeSidebar() {
|
||||||
this.isSidebarOpen = false;
|
|
||||||
this.nearbyPlaces = null;
|
this.nearbyPlaces = null;
|
||||||
this.selectedPlace = null;
|
this.router.transitionTo('index');
|
||||||
}
|
}
|
||||||
|
|
||||||
@action
|
@action
|
||||||
refreshBookmarks() {
|
refreshBookmarks() {
|
||||||
this.bookmarksVersion++;
|
this.storage.notifyChange();
|
||||||
}
|
}
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
{{pageTitle "M/\RCO"}}
|
{{pageTitle "M/\RCO"}}
|
||||||
|
|
||||||
<Map
|
<Map
|
||||||
@onPlacesFound={{this.showPlaces}}
|
@onPlacesFound={{this.showPlaces}}
|
||||||
@isSidebarOpen={{this.isSidebarOpen}}
|
@isSidebarOpen={{this.isSidebarOpen}}
|
||||||
@onOutsideClick={{this.closeSidebar}}
|
@onOutsideClick={{this.closeSidebar}}
|
||||||
@bookmarksVersion={{this.bookmarksVersion}}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{{#if this.isSidebarOpen}}
|
{{#if (and (eq this.router.currentRouteName "index") this.nearbyPlaces)}}
|
||||||
<PlacesSidebar
|
<PlacesSidebar
|
||||||
@places={{this.nearbyPlaces}}
|
@places={{this.nearbyPlaces}}
|
||||||
@selectedPlace={{this.selectedPlace}}
|
@onSelect={{this.selectFromList}}
|
||||||
@onSelect={{this.selectPlace}}
|
@onClose={{this.closeSidebar}}
|
||||||
@onClose={{this.closeSidebar}}
|
|
||||||
@onBookmarkChange={{this.refreshBookmarks}}
|
|
||||||
/>
|
/>
|
||||||
{{/if}}
|
{{/if}}
|
||||||
|
|
||||||
|
|||||||
68
app/templates/place.gjs
Normal file
68
app/templates/place.gjs
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import Component from '@glimmer/component';
|
||||||
|
import PlacesSidebar from '#components/places-sidebar';
|
||||||
|
import { service } from '@ember/service';
|
||||||
|
import { action } from '@ember/object';
|
||||||
|
import { tracked } from '@glimmer/tracking';
|
||||||
|
|
||||||
|
export default class PlaceTemplate extends Component {
|
||||||
|
@service router;
|
||||||
|
@service storage;
|
||||||
|
|
||||||
|
@tracked localPlace = null;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super(...arguments);
|
||||||
|
this.localPlace = this.args.model;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update local place if model changes (e.g. navigation)
|
||||||
|
// We can use a getter or an effect, but in GJS a getter is easiest if we don't need manual overrides often.
|
||||||
|
// But we DO need to override it when saving.
|
||||||
|
|
||||||
|
// Actually, we can just use a derived state that prefers the local override?
|
||||||
|
// Let's use a modifier or just sync it.
|
||||||
|
|
||||||
|
get place() {
|
||||||
|
// If we have a manually updated place (from save), use it.
|
||||||
|
// Otherwise use the route model.
|
||||||
|
// We need to ensure we reset `localPlace` when navigating to a NEW place.
|
||||||
|
// Comparing IDs is a safe bet.
|
||||||
|
|
||||||
|
const model = this.args.model;
|
||||||
|
if (
|
||||||
|
this.localPlace &&
|
||||||
|
(this.localPlace.id === model.id || this.localPlace.osmId === model.osmId)
|
||||||
|
) {
|
||||||
|
// If the local place is "richer" (has createdAt), prefer it.
|
||||||
|
if (this.localPlace.createdAt && !model.createdAt) return this.localPlace;
|
||||||
|
// If we deleted it (local has no createdAt, model might?) - wait, if we delete, we close sidebar.
|
||||||
|
}
|
||||||
|
return model;
|
||||||
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
handleUpdate(newPlace) {
|
||||||
|
console.log('Updating local place state:', newPlace);
|
||||||
|
this.localPlace = newPlace;
|
||||||
|
this.storage.notifyChange();
|
||||||
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
refreshMap() {
|
||||||
|
this.storage.notifyChange();
|
||||||
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
close() {
|
||||||
|
this.router.transitionTo('index');
|
||||||
|
}
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<PlacesSidebar
|
||||||
|
@selectedPlace={{this.place}}
|
||||||
|
@onClose={{this.close}}
|
||||||
|
@onBookmarkChange={{this.refreshMap}}
|
||||||
|
@onUpdate={{this.handleUpdate}}
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user