WIP show POI list on click, save to RS

This commit is contained in:
2026-01-16 10:47:05 +07:00
parent 46079e96e3
commit 5f6a13386b
9 changed files with 525 additions and 22 deletions

View File

@@ -7,25 +7,50 @@ import { defaults as defaultControls } from 'ol/control.js';
import View from 'ol/View.js';
import { fromLonLat, toLonLat } from 'ol/proj.js';
import LayerGroup from 'ol/layer/Group.js';
import VectorLayer from 'ol/layer/Vector.js';
import VectorSource from 'ol/source/Vector.js';
import Feature from 'ol/Feature.js';
import Point from 'ol/geom/Point.js';
import { Style, Circle, Fill, Stroke } from 'ol/style.js';
import { apply } from 'ol-mapbox-style';
import { getDistance } from '../utils/geo';
export default class MapComponent extends Component {
@service osm;
@service storage;
mapInstance;
bookmarkSource;
setupMap = modifier((element) => {
if (this.mapInstance) return;
const openfreemap = new LayerGroup();
// Create a vector source and layer for bookmarks
this.bookmarkSource = new VectorSource();
const bookmarkLayer = new VectorLayer({
source: this.bookmarkSource,
style: new Style({
image: new Circle({
radius: 7,
fill: new Fill({ color: '#ffcc33' }), // Gold/Yellow
stroke: new Stroke({
color: '#fff',
width: 2,
}),
}),
}),
zIndex: 10, // Ensure it sits above the map tiles
});
this.mapInstance = new Map({
target: element,
layers: [openfreemap],
layers: [openfreemap, bookmarkLayer],
controls: defaultControls({ zoom: false }),
view: new View({
center: fromLonLat([99.05738, 7.56087]),
zoom: 12.5,
center: fromLonLat([99.04738, 7.58087]),
zoom: 13.0,
projection: 'EPSG:3857',
}),
});
@@ -40,34 +65,127 @@ export default class MapComponent extends Component {
const hit = this.mapInstance.hasFeatureAtPixel(pixel);
this.mapInstance.getTarget().style.cursor = hit ? 'pointer' : '';
});
// Load initial bookmarks
this.loadBookmarks();
// Listen for remote storage changes
this.storage.rs.on('connected', () => {
this.loadBookmarks();
});
this.storage.places.on('change', (event) => {
// Ideally we would only update the changed one, but refreshing all is safer for now
this.loadBookmarks();
});
});
handleMapClick = async (event) => {
// 1. Check if user clicked on a rendered feature (POI)
const features = this.mapInstance.getFeaturesAtPixel(event.pixel);
if (features && features.length > 0) {
// Prioritize POIs (features with names/amenities)
// OpenLayers features from vector tiles have properties like 'name', 'class', 'subclass', etc.
const clickedFeature = features[0];
const props = clickedFeature.getProperties();
// Basic check: does it look like a POI? (has a name or distinct class)
if (props.name || props.class) {
console.log('Clicked Feature (POI):', props);
return; // Stop here, we found a direct click
async loadBookmarks() {
try {
// Wait a moment for RemoteStorage to be ready (if needed),
// or just try fetching. The 'connected' event is better but for now:
const places = await this.storage.places.listAll();
this.bookmarkSource.clear();
if (places && Array.isArray(places)) {
places.forEach(place => {
if (place.lat && place.lon) {
const feature = new Feature({
geometry: new Point(fromLonLat([place.lon, place.lat])),
name: place.title,
id: place.id,
isBookmark: true, // Marker property to distinguish
originalPlace: place
});
this.bookmarkSource.addFeature(feature);
}
});
}
} catch (e) {
console.error("Failed to load bookmarks:", e);
}
}
// 2. Fallback: Fetch nearby POIs via Overpass API
handleMapClick = async (event) => {
const coords = toLonLat(event.coordinate);
const [lon, lat] = coords;
console.log(`No feature clicked. Searching nearby at: ${lat}, ${lon}`);
// 1. Check if user clicked on a rendered feature (POI or Bookmark)
const features = this.mapInstance.getFeaturesAtPixel(event.pixel);
let selectedFeatureName = null;
let selectedFeatureType = null;
let clickedBookmark = null;
if (features && features.length > 0) {
// Prioritize bookmarks if clicked
const bookmarkFeature = features.find(f => f.get('isBookmark'));
if (bookmarkFeature) {
clickedBookmark = bookmarkFeature.get('originalPlace');
console.log("Clicked bookmark:", clickedBookmark);
// Notify parent to show bookmark details
if (this.args.onPlacesFound) {
// We pass it as a "selectedPlace" but with an empty list of nearby items since it's a specific bookmark
this.args.onPlacesFound([], clickedBookmark);
}
return; // Stop processing to avoid fetching OSM data for a known bookmark
}
const props = features[0].getProperties();
if (props.name) {
selectedFeatureName = props.name;
selectedFeatureType = props.class || props.subclass; // e.g., 'cafe'
console.log(`Clicked visual feature: "${selectedFeatureName}" (${selectedFeatureType})`);
}
}
// 2. Fetch authoritative data via Overpass
try {
const pois = await this.osm.getNearbyPois(lat, lon);
console.log('Nearby POIs:', pois);
const searchRadius = selectedFeatureName ? 50 : 200;
let pois = await this.osm.getNearbyPois(lat, lon, searchRadius);
// Sort by distance from click
pois = pois.map(p => {
// Use center lat/lon for ways/relations if available, else lat/lon
const pLat = p.lat || p.center?.lat;
const pLon = p.lon || p.center?.lon;
return {
...p,
_distance: (pLat && pLon) ? getDistance(lat, lon, pLat, pLon) : 9999
};
}).sort((a, b) => a._distance - b._distance);
let matchedPlace = null;
if (selectedFeatureName && pois.length > 0) {
// Heuristic:
// 1. Exact Name Match
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
if (!matchedPlace) {
const topCandidate = pois[0];
if (topCandidate._distance <= 20) {
// Check type compatibility if available
// (visual tile 'class' is often 'cafe', osm tag is 'amenity'='cafe')
const pType = topCandidate.tags.amenity || topCandidate.tags.shop || topCandidate.tags.tourism;
if (selectedFeatureType && pType && (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) {
this.args.onPlacesFound(pois, matchedPlace);
}
} catch (error) {
console.error('Failed to fetch POIs:', error);
}

View File

@@ -0,0 +1,146 @@
import Component from '@glimmer/component';
import { service } from '@ember/service';
import { action } from '@ember/object';
import { tracked } from '@glimmer/tracking';
import { on } from '@ember/modifier';
import { fn } from '@ember/helper';
import or from 'ember-truth-helpers/helpers/or';
export default class PlacesSidebar extends Component {
@service storage;
@tracked selectedPlace = null;
constructor() {
super(...arguments);
// If a specific place was passed in (pre-selected by map), show it immediately
if (this.args.initialPlace) {
this.selectedPlace = this.args.initialPlace;
}
}
@action
selectPlace(place) {
this.selectedPlace = place;
}
@action
clearSelection() {
this.selectedPlace = null;
// If we were initialized with a single place (direct click),
// going "back" might mean closing or showing the list if available.
// Logic: if we have a list (@places), go back to list.
// If we only had one place (@initialPlace) and no list, maybe close?
// For now, assuming @places is always passed if we want a list fallback.
if (!this.args.places || this.args.places.length === 0) {
this.args.onClose();
}
}
@action
async savePlace(poi) {
if (!poi) return;
// Map Overpass POI to our Place schema
const placeData = {
title: poi.tags.name || poi.tags['name:en'] || 'Untitled Place',
lat: poi.lat,
lon: poi.lon,
tags: [],
url: poi.tags.website,
osmId: String(poi.id),
// We rely on the module to generate ID and Geohash
};
try {
await this.storage.places.store(placeData);
console.log('Place saved:', placeData.title);
alert('Place saved!'); // Quick feedback for now
// Notify the map component to refresh bookmarks if possible.
// Since we don't have a direct callback here yet, we might rely on
// RemoteStorage events or just simple refresh if the map listens.
if (this.args.onBookmarkSaved) {
this.args.onBookmarkSaved();
}
if (this.args.onClose) {
this.args.onClose();
}
} catch (error) {
console.error('Failed to save place:', error);
alert('Failed to save place: ' + error.message);
}
}
<template>
<div class="sidebar">
<div class="sidebar-header">
{{#if this.selectedPlace}}
<button type="button" class="back-btn" {{on "click" this.clearSelection}}>←</button>
<h2>Details</h2>
{{else}}
<h2>Nearby Places</h2>
{{/if}}
<button type="button" class="close-btn" {{on "click" @onClose}}>×</button>
</div>
<div class="sidebar-content">
{{#if this.selectedPlace}}
<div class="place-details">
<h3>{{or this.selectedPlace.title this.selectedPlace.tags.name this.selectedPlace.tags.name:en "Unnamed Place"}}</h3>
<p class="place-meta">
{{#if this.selectedPlace.tags.amenity}}
{{or this.selectedPlace.tags.amenity this.selectedPlace.tags.shop this.selectedPlace.tags.tourism}}
{{else}}
{{!-- If it is a bookmark, it might just have an array of tags or description --}}
{{this.selectedPlace.description}}
{{/if}}
</p>
{{#if (or this.selectedPlace.url this.selectedPlace.tags.website)}}
<p><a href={{or this.selectedPlace.url this.selectedPlace.tags.website}} target="_blank" rel="noopener noreferrer">Website</a></p>
{{/if}}
{{#if this.selectedPlace.tags.opening_hours}}
<p><strong>Open:</strong> {{this.selectedPlace.tags.opening_hours}}</p>
{{/if}}
<div class="actions">
{{!-- Only show save button if it doesn't look like a saved bookmark (bookmarks have 'createdAt') --}}
{{#unless this.selectedPlace.createdAt}}
<button type="button" class="btn-primary" {{on "click" (fn this.savePlace this.selectedPlace)}}>
Save Bookmark
</button>
{{else}}
<button type="button" class="btn-secondary" disabled>
Saved ✓
</button>
{{/unless}}
</div>
<div class="meta-info">
{{#if (or this.selectedPlace.osmId this.selectedPlace.id)}}
<p><small>OSM ID: <a href="https://www.openstreetmap.org/{{if this.selectedPlace.type this.selectedPlace.type 'node'}}/{{or this.selectedPlace.osmId this.selectedPlace.id}}" target="_blank" rel="noopener noreferrer">{{or this.selectedPlace.osmId this.selectedPlace.id}}</a></small></p>
{{/if}}
</div>
</div>
{{else}}
{{#if @places}}
<ul class="places-list">
{{#each @places as |place|}}
<li>
<button type="button" 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>
</li>
{{/each}}
</ul>
{{else}}
<p class="empty-state">No places found nearby.</p>
{{/if}}
{{/if}}
</div>
</div>
</template>
}