WIP show POI list on click, save to RS
This commit is contained in:
parent
46079e96e3
commit
5f6a13386b
65
PROJECT_STATUS.md
Normal file
65
PROJECT_STATUS.md
Normal file
@ -0,0 +1,65 @@
|
||||
# Project Status: Marco
|
||||
|
||||
**Last Updated:** Fri Jan 16 2026
|
||||
|
||||
## Project Context
|
||||
|
||||
We are building **Marco**, a decentralized maps application using **Ember.js** (Octane/Polaris edition with GJS/GLIMMER), **Vite**, and **OpenLayers**. The core feature is storing place bookmarks in **RemoteStorage.js**, using a custom module structure.
|
||||
|
||||
## What We Have Done
|
||||
|
||||
### 1. Map Integration
|
||||
|
||||
- Set up OpenLayers in `app/components/map.gjs` (class-based component).
|
||||
- Switched tiles to **OpenFreeMap Liberty** style (supports vector POIs).
|
||||
- Implemented a hybrid click handler:
|
||||
- Detects clicks on visual vector tiles.
|
||||
- Falls back to fetching authoritative data from an **Overpass API** service.
|
||||
- Uses a **heuristic** (distance + type matching) to link visual clicks to API results (handling data desynchronization).
|
||||
|
||||
### 2. RemoteStorage Module (`@remotestorage/module-places`)
|
||||
|
||||
- Created a custom TypeScript module in `vendor/remotestorage-module-places/`.
|
||||
- **Schema:** `place` object containing `id` (ULID), `title`, `lat`, `lon`, `geohash`, `osmId`, `url`, etc.
|
||||
- **Storage Path:** `places/<geohash-prefix-6>/<ulid>` (groups places by location).
|
||||
- **Dependencies:** Uses `ulid` and `latlon-geohash` internally (bundled via `npm run build`).
|
||||
- **Fixes:** Refactored to `async/await`, removed manual `try/catch` validation, and added logic to strip `undefined` fields to satisfy RemoteStorage validation.
|
||||
|
||||
### 3. App Infrastructure
|
||||
|
||||
- **Services:**
|
||||
- `storage.js`: Initializes RemoteStorage, claims access, enables caching, and sets up the widget.
|
||||
- `osm.js`: Fetches nearby POIs from Overpass API.
|
||||
- **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.
|
||||
- **Geo Utils:** Added `app/utils/geo.js` for Haversine distance calculations.
|
||||
|
||||
## Current State
|
||||
|
||||
- **Repo:** The app runs via `pnpm start`.
|
||||
- **Workflow:**
|
||||
1. User clicks map -> Sidebar opens.
|
||||
2. If a POI is matched heuristically, it opens "Details" directly.
|
||||
3. Otherwise, it lists nearby places.
|
||||
4. User clicks "Save Bookmark" -> Stores JSON in RemoteStorage.
|
||||
- **Recent Fixes:** Corrected an issue where saving failed due to `undefined` URL fields. Added `osmId` to the schema and sidebar UI.
|
||||
|
||||
## Files Currently in Focus
|
||||
|
||||
- `vendor/remotestorage-module-places/src/places.ts`: The data module logic (schema, pathing, storage). **Requires `npm run build` in this directory after changes.**
|
||||
- `app/components/map.gjs`: Map rendering and click interaction logic.
|
||||
- `app/components/places-sidebar.gjs`: UI for listing POIs and saving them.
|
||||
- `app/services/storage.js`: RemoteStorage configuration.
|
||||
|
||||
## Next Steps & Pending Tasks
|
||||
|
||||
1. **Rendering Bookmarks:** We are saving places, but **not yet displaying them on the map**. We need to fetch stored places from RemoteStorage (likely via `listByPrefix` based on the current map view) and render them as markers on the OpenLayers map.
|
||||
2. **UI Feedback:** Verify the "Save" action gives better visual feedback than `alert()`.
|
||||
3. **Widget:** The RemoteStorage widget attachment code in `storage.js` is currently commented out; it needs to be enabled or properly placed in the UI.
|
||||
4. **Refinement:** The `osmId` property was just added; verify the link in the sidebar works correctly.
|
||||
|
||||
## Technical Constraints
|
||||
|
||||
- **Template Style:** Strict Mode GJS (`<template>`).
|
||||
- **Package Manager:** `pnpm` for the main app, `npm` for the vendor module.
|
||||
- **Visuals:** No Tailwind/Bootstrap; using custom CSS in `app/styles/app.css`.
|
||||
@ -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);
|
||||
}
|
||||
|
||||
146
app/components/places-sidebar.gjs
Normal file
146
app/components/places-sidebar.gjs
Normal 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>
|
||||
}
|
||||
@ -18,6 +18,7 @@ export default class StorageService extends Service {
|
||||
this.rs.caching.enable('/places/');
|
||||
|
||||
window.remoteStorage = this.rs;
|
||||
|
||||
// const widget = new Widget(this.rs);
|
||||
// widget.attach();
|
||||
}
|
||||
|
||||
@ -26,3 +26,118 @@ body {
|
||||
right: 10px;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
/* Sidebar Styles */
|
||||
.sidebar {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
width: 300px;
|
||||
background: white;
|
||||
z-index: 2000;
|
||||
box-shadow: 2px 0 5px rgba(0, 0, 0, 0.1);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
padding: 1rem;
|
||||
border-bottom: 1px solid #eee;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.sidebar-header h2 {
|
||||
margin: 0;
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 1.5rem;
|
||||
cursor: pointer;
|
||||
padding: 0 0.5rem;
|
||||
}
|
||||
|
||||
.back-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 1.2rem;
|
||||
cursor: pointer;
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
.place-details {
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.place-details h3 {
|
||||
margin-top: 0;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.place-meta {
|
||||
color: #666;
|
||||
text-transform: capitalize;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: #007bff;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 0.75rem 1.5rem;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
width: 100%;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: #0056b3;
|
||||
}
|
||||
|
||||
.places-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.places-list li {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.place-item {
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
background: #f8f9fa;
|
||||
border: 1px solid #ddd;
|
||||
padding: 0.75rem;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.place-item:hover {
|
||||
background: #e9ecef;
|
||||
}
|
||||
|
||||
.place-name {
|
||||
font-weight: bold;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.place-type {
|
||||
font-size: 0.85rem;
|
||||
color: #666;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
color: #666;
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
@ -1,11 +1,18 @@
|
||||
import Component from '@glimmer/component';
|
||||
import { pageTitle } from 'ember-page-title';
|
||||
import Map from '#components/map';
|
||||
import PlacesSidebar from '#components/places-sidebar';
|
||||
import { service } from '@ember/service';
|
||||
import { tracked } from '@glimmer/tracking';
|
||||
import { action } from '@ember/object';
|
||||
|
||||
export default class ApplicationComponent extends Component {
|
||||
@service storage;
|
||||
|
||||
@tracked nearbyPlaces = null;
|
||||
@tracked selectedPlace = null;
|
||||
@tracked isSidebarOpen = false;
|
||||
|
||||
constructor() {
|
||||
super(...arguments);
|
||||
console.log('Application component constructed');
|
||||
@ -13,10 +20,34 @@ export default class ApplicationComponent extends Component {
|
||||
this.storage;
|
||||
}
|
||||
|
||||
@action
|
||||
showPlaces(places, selectedPlace = null) {
|
||||
this.nearbyPlaces = places;
|
||||
this.selectedPlace = selectedPlace;
|
||||
this.isSidebarOpen = true;
|
||||
}
|
||||
|
||||
@action
|
||||
closeSidebar() {
|
||||
this.isSidebarOpen = false;
|
||||
this.nearbyPlaces = null;
|
||||
this.selectedPlace = null;
|
||||
}
|
||||
|
||||
<template>
|
||||
{{pageTitle "M/\RCO"}}
|
||||
|
||||
<Map />
|
||||
<Map
|
||||
@onPlacesFound={{this.showPlaces}}
|
||||
/>
|
||||
|
||||
{{#if this.isSidebarOpen}}
|
||||
<PlacesSidebar
|
||||
@places={{this.nearbyPlaces}}
|
||||
@initialPlace={{this.selectedPlace}}
|
||||
@onClose={{this.closeSidebar}}
|
||||
/>
|
||||
{{/if}}
|
||||
|
||||
{{outlet}}
|
||||
</template>
|
||||
|
||||
14
app/utils/geo.js
Normal file
14
app/utils/geo.js
Normal file
@ -0,0 +1,14 @@
|
||||
export function getDistance(lat1, lon1, lat2, lon2) {
|
||||
const R = 6371e3; // metres
|
||||
const φ1 = (lat1 * Math.PI) / 180; // φ, λ in radians
|
||||
const φ2 = (lat2 * Math.PI) / 180;
|
||||
const Δφ = ((lat2 - lat1) * Math.PI) / 180;
|
||||
const Δλ = ((lon2 - lon1) * Math.PI) / 180;
|
||||
|
||||
const a =
|
||||
Math.sin(Δφ / 2) * Math.sin(Δφ / 2) +
|
||||
Math.cos(φ1) * Math.cos(φ2) * Math.sin(Δλ / 2) * Math.sin(Δλ / 2);
|
||||
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||
|
||||
return R * c; // in metres
|
||||
}
|
||||
@ -86,6 +86,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@remotestorage/module-places": "link:vendor/remotestorage-module-places",
|
||||
"ember-truth-helpers": "^5.0.0",
|
||||
"ol": "^10.7.0",
|
||||
"ol-mapbox-style": "^13.2.0",
|
||||
"remotestorage-widget": "^1.8.0",
|
||||
|
||||
12
pnpm-lock.yaml
generated
12
pnpm-lock.yaml
generated
@ -11,6 +11,9 @@ importers:
|
||||
'@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
|
||||
ol:
|
||||
specifier: ^10.7.0
|
||||
version: 10.7.0
|
||||
@ -2259,6 +2262,9 @@ packages:
|
||||
engines: {node: ^18.18.0 || >= 20.9.0}
|
||||
hasBin: true
|
||||
|
||||
ember-truth-helpers@5.0.0:
|
||||
resolution: {integrity: sha512-PnQd6D6hvlNC3k6gBu0SC2cvfXX6wH6W0nToomIIoxqyrD5cllk0zBh/j/1H0KsczVCWeuF9PWj5xJgL4jQAGg==}
|
||||
|
||||
ember-welcome-page@8.0.5:
|
||||
resolution: {integrity: sha512-XegmXI94mt/EVUD3Ya15OuAxB3iCdK16lGS7vCimUIi4fGGooxUTOnlKexTsVLbcJh8u2bsw5JZ0iVht1X5sng==}
|
||||
|
||||
@ -6789,6 +6795,12 @@ snapshots:
|
||||
'@lint-todo/utils': 13.1.1
|
||||
content-tag: 3.1.3
|
||||
|
||||
ember-truth-helpers@5.0.0:
|
||||
dependencies:
|
||||
'@embroider/addon-shim': 1.10.2
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
ember-welcome-page@8.0.5(@babel/core@7.28.6):
|
||||
dependencies:
|
||||
'@embroider/addon-shim': 1.10.2
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user