17 Commits

Author SHA1 Message Date
99aeee51bd 1.3.1 2026-01-21 19:39:52 +07:00
fa4115b714 Pan map on mobile when pin obscured by sidebar 2026-01-21 19:38:55 +07:00
360e511849 1.3.0 2026-01-21 19:16:22 +07:00
0fee9ad2dd Link to bookmarked places from nearby places 2026-01-21 19:15:38 +07:00
c61c2c0e7a Show map pin for currently selected place 2026-01-21 19:07:35 +07:00
25f50f9091 Update status doc 2026-01-21 18:32:37 +07:00
cf9139b9c1 1.2.1 2026-01-21 16:10:58 +07:00
01c3b5a1ac Improve place details with icons 2026-01-21 16:10:22 +07:00
3fcaa0bfa2 Move all deps to devDependencies, install feather-icons 2026-01-21 15:00:31 +07:00
0074b63ab2 1.2.0 2026-01-21 14:55:57 +07:00
da3b5f2dd8 Move place details to dedicated component
With more place infos and formatting
2026-01-21 14:53:58 +07:00
26548cc97d 1.1.1 2026-01-21 13:09:11 +07:00
babf2c4a89 Enable default rotation control 2026-01-21 13:08:35 +07:00
fbb90a330b Remove obsolete dist files from Git 2026-01-21 13:08:21 +07:00
15cbb3c9f3 Fix deprecation warning 2026-01-21 13:07:30 +07:00
b6fd4aaea8 1.1.0 2026-01-21 11:24:05 +07:00
696d4b0ae3 Store current map view, use on launch/reload 2026-01-21 11:22:46 +07:00
23 changed files with 666 additions and 161 deletions

View File

@@ -1,6 +1,6 @@
# Project Status: Marco
**Last Updated:** Mon Jan 19 2026
**Last Updated:** Wed Jan 21 2026
## Project Context
@@ -25,6 +25,8 @@ We are building **Marco**, a decentralized maps application using **Ember.js** (
- **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.
- **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.
### 2. RemoteStorage Module (`@remotestorage/module-places`)
@@ -46,7 +48,10 @@ We are building **Marco**, a decentralized maps application using **Ember.js** (
- `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.
- **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.
- `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:**
- `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.
@@ -64,25 +69,23 @@ We are building **Marco**, a decentralized maps application using **Ember.js** (
1. User pans map -> `moveend` triggers `storage.loadPlacesInBounds`.
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.
4. Sidebar displays details (using normalized `osmTags`).
4. Sidebar displays details via `<PlaceDetails>` component.
5. User clicks "Save Bookmark" -> Stores JSON in RemoteStorage.
6. RemoteStorage change event -> Debounced reload updates the map reactive-ly.
## Files Currently in Focus
- `app/routes/place.js`: Routing logic, ID parsing, and URL serialization.
- `app/services/osm.js`: Data fetching and normalization.
- `app/components/place-details.gjs`: UI logic for place info.
- `app/routes/place.js`: Routing logic.
- `app/components/map.gjs`: Map rendering and interaction.
- `app/services/storage.js`: Data sync logic.
## Next Steps & Pending Tasks
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.
3. **Edit Bookmarks:** Allow users to edit the title and description of saved places.
4. **Refine UI/UX:** Further polish sidebar interactions and mobile responsiveness.
5. **Performance:** Monitor performance with large datasets (thousands of bookmarks).
6. **Testing:** Add automated tests for the geohash coverage and retry logic.
2. **Edit Bookmarks:** Allow users to edit the title and description of saved places.
3. **Performance:** Monitor performance with large datasets (thousands of bookmarks).
4. **Testing:** Add automated tests for the geohash coverage and retry logic.
## Technical Constraints

54
app/components/icon.gjs Normal file
View 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 || '#888';
}
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>
}

View File

@@ -16,15 +16,19 @@ import Geolocation from 'ol/Geolocation.js';
import { Style, Circle, Fill, Stroke } from 'ol/style.js';
import { apply } from 'ol-mapbox-style';
import { getDistance } from '../utils/geo';
import Icon from '../components/icon';
export default class MapComponent extends Component {
@service osm;
@service storage;
@service mapUi;
mapInstance;
bookmarkSource;
searchOverlay;
searchOverlayElement;
selectedPinOverlay;
selectedPinElement;
setupMap = modifier((element) => {
if (this.mapInstance) return;
@@ -57,9 +61,33 @@ export default class MapComponent extends Component {
zIndex: 10, // Ensure it sits above the map tiles
});
// Default view settings
let center = [99.05738, 7.55087];
let zoom = 13.0;
// Try to restore from localStorage
try {
const storedView = localStorage.getItem('marco:map-view');
if (storedView) {
const parsed = JSON.parse(storedView);
if (
parsed.center &&
Array.isArray(parsed.center) &&
parsed.center.length === 2 &&
typeof parsed.zoom === 'number'
) {
center = parsed.center;
zoom = parsed.zoom;
}
}
} catch (e) {
console.warn('Failed to restore map view:', e);
}
const view = new View({
center: fromLonLat([99.05738, 7.55087]),
zoom: 13.0,
center: fromLonLat(center),
zoom: zoom,
projection: 'EPSG:3857',
});
@@ -67,7 +95,7 @@ export default class MapComponent extends Component {
target: element,
layers: [openfreemap, bookmarkLayer],
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');
@@ -81,6 +109,34 @@ export default class MapComponent extends Component {
});
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
this.locationOverlayElement = document.createElement('div');
this.locationOverlayElement.className = 'search-pulse blue';
@@ -288,6 +344,65 @@ 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.panIfObscured(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);
}
});
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);
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)
updateBookmarks = modifier(() => {
// Depend on the tracked storage.savedPlaces to automatically update when they change
@@ -341,6 +456,22 @@ export default class MapComponent extends Component {
const bbox = { minLat, minLon, maxLat, maxLon };
await this.storage.loadPlacesInBounds(bbox);
this.loadBookmarks(this.storage.savedPlaces);
// Persist view to localStorage
try {
const view = this.mapInstance.getView();
const currentCenter = toLonLat(view.getCenter());
const currentZoom = view.getZoom();
const viewState = {
center: currentCenter,
zoom: currentZoom
};
localStorage.setItem('marco:map-view', JSON.stringify(viewState));
} catch (e) {
console.warn('Failed to save map view:', e);
}
};
handleMapClick = async (event) => {
@@ -492,6 +623,7 @@ export default class MapComponent extends Component {
class="map-container"
{{this.setupMap}}
{{this.updateBookmarks}}
{{this.updateSelectedPin}}
style="position: absolute; inset: 0;"
></div>
</template>

View File

@@ -0,0 +1,198 @@
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 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}`;
}
<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">Website</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}}
<hr class="meta-divider">
{{#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}}
</div>
</div>
</template>
}

View File

@@ -4,6 +4,7 @@ import { action } from '@ember/object';
import { on } from '@ember/modifier';
import { fn } from '@ember/helper';
import or from 'ember-truth-helpers/helpers/or';
import PlaceDetails from './place-details';
export default class PlacesSidebar extends Component {
@service storage;
@@ -160,77 +161,10 @@ export default class PlacesSidebar extends Component {
<div class="sidebar-content">
{{#if @selectedPlace}}
<div class="place-details">
<h3>{{or
@selectedPlace.title
@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>
<PlaceDetails
@place={{@selectedPlace}}
@onToggleSave={{this.toggleSave}}
/>
{{else}}
{{#if @places}}
<ul class="places-list">

View 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);

View File

@@ -4,6 +4,7 @@ import { service } from '@ember/service';
export default class PlaceRoute extends Route {
@service storage;
@service osm;
@service mapUi;
async model(params) {
const id = params.place_id;
@@ -16,15 +17,8 @@ export default class PlaceRoute extends Route {
}
// 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;
@@ -35,6 +29,18 @@ export default class PlaceRoute extends Route {
return this.loadOsmPlace(id);
}
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) {
try {
const poi = await this.osm.getPoiById(id, type);

14
app/services/map-ui.js Normal file
View 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;
}
}

View File

@@ -37,6 +37,7 @@ body {
bottom: 0;
width: 300px;
background: white;
color: #333;
z-index: 2000;
box-shadow: 2px 0 5px rgb(0 0 0 / 10%);
display: flex;
@@ -56,6 +57,10 @@ body {
font-size: 1.2rem;
}
.sidebar-content {
padding: 1rem;
}
.close-btn {
background: none;
border: none;
@@ -73,18 +78,22 @@ body {
}
.place-details {
padding: 0.5rem;
}
.place-details h3 {
font-size: 1.2rem;
margin-top: 0;
margin-bottom: 0.5rem;
}
.place-meta {
.place-details .place-type {
color: #666;
font-size: 0.9rem;
text-transform: capitalize;
margin-bottom: 1rem;
margin: 0 0 1rem 0;
}
.place-details .place-description {
}
.btn-primary {
@@ -133,15 +142,42 @@ body {
}
.place-type {
font-size: 0.85rem;
color: #666;
font-size: 0.85rem;
text-transform: capitalize;
}
.empty-state {
text-align: center;
color: #666;
margin-top: 2rem;
.meta-info {
margin-top: 1.5rem;
padding-top: 1rem;
border-top: 1px solid #eee;
font-size: 0.9rem;
text-align: left;
}
.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;
}
.meta-info a:hover {
text-decoration: underline;
}
.meta-divider {
border: 0;
border-top: 1px dashed #ddd;
margin: 1rem 0;
}
/* Map Search Pulse Animation */
@@ -187,3 +223,112 @@ body {
.ol-touch .ol-control.ol-locate {
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;
/* Ensure content doesn't get hidden behind bottom safe areas on mobile */
padding-bottom: env(safe-area-inset-bottom, 20px);
}
}

View File

@@ -28,15 +28,26 @@ export default class ApplicationComponent extends Component {
@action
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 (selectedPlace) {
if (resolvedSelected) {
// Pass the FULL object model to avoid re-fetching!
// 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
} else if (places && places.length > 0) {
} else if (resolvedPlaces && resolvedPlaces.length > 0) {
// Show list case
this.nearbyPlaces = places;
this.nearbyPlaces = resolvedPlaces;
this.router.transitionTo('index');
}
}

View File

@@ -17,7 +17,7 @@ export default {
[
'babel-plugin-ember-template-compilation',
{
compilerPath: 'ember-source/dist/ember-template-compiler.js',
compilerPath: 'ember-source/ember-template-compiler/index.js',
transforms: [...macros.templateMacros],
},
],

View File

View File

@@ -1 +0,0 @@
var runningTests=false;

15
dist/index.html vendored
View File

@@ -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
View File

@@ -1,3 +0,0 @@
# https://www.robotstxt.org/
User-agent: *
Disallow:

View File

@@ -1,6 +1,6 @@
{
"name": "marco",
"version": "1.0.0",
"version": "1.3.1",
"private": true,
"description": "Small description for marco goes here",
"repository": "",
@@ -46,6 +46,7 @@
"@embroider/vite": "^1.5.0",
"@eslint/js": "^9.39.2",
"@glimmer/component": "^2.0.0",
"@remotestorage/module-places": "link:vendor/remotestorage-module-places",
"@rollup/plugin-babel": "^6.1.0",
"@warp-drive/core": "~5.8.0",
"@warp-drive/ember": "~5.8.0",
@@ -62,6 +63,7 @@
"ember-resolver": "^13.1.1",
"ember-source": "~6.11.0-alpha.6",
"ember-template-lint": "^7.9.3",
"ember-truth-helpers": "^5.0.0",
"ember-welcome-page": "^8.0.4",
"eslint": "^9.39.2",
"eslint-config-prettier": "^10.1.8",
@@ -69,11 +71,17 @@
"eslint-plugin-n": "^17.23.1",
"eslint-plugin-qunit": "^8.2.5",
"eslint-plugin-warp-drive": "^5.8.0",
"feather-icons": "^4.29.2",
"globals": "^16.5.0",
"latlon-geohash": "^2.0.0",
"ol": "^10.7.0",
"ol-mapbox-style": "^13.2.0",
"prettier": "^3.7.4",
"prettier-plugin-ember-template-tag": "^2.1.2",
"qunit": "^2.25.0",
"qunit-dom": "^3.5.0",
"remotestorage-widget": "^1.8.0",
"remotestoragejs": "2.0.0-beta.8",
"sinon": "^21.0.1",
"stylelint": "^16.26.1",
"stylelint-config-standard": "^38.0.0",
@@ -85,14 +93,5 @@
},
"ember": {
"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
View File

@@ -7,28 +7,6 @@ settings:
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:
'@babel/core':
specifier: ^7.28.5
@@ -69,6 +47,9 @@ importers:
'@glimmer/component':
specifier: ^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':
specifier: ^6.1.0
version: 6.1.0(@babel/core@7.28.6)(rollup@4.55.1)
@@ -117,6 +98,9 @@ importers:
ember-template-lint:
specifier: ^7.9.3
version: 7.9.3
ember-truth-helpers:
specifier: ^5.0.0
version: 5.0.0
ember-welcome-page:
specifier: ^8.0.4
version: 8.0.5(@babel/core@7.28.6)
@@ -138,9 +122,21 @@ importers:
eslint-plugin-warp-drive:
specifier: ^5.8.0
version: 5.8.1(@babel/core@7.28.6)
feather-icons:
specifier: ^4.29.2
version: 4.29.2
globals:
specifier: ^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:
specifier: ^3.7.4
version: 3.7.4
@@ -153,6 +149,12 @@ importers:
qunit-dom:
specifier: ^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:
specifier: ^21.0.1
version: 21.0.1
@@ -1794,6 +1796,9 @@ packages:
charm@1.0.2:
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:
resolution: {integrity: sha512-PHGlEF0Z6976qQyN6gM7kKH6EH0RdfZcc8V+QhFe36eRxV0SMH5OUBZG7Bxa9YcreNzyNbK63cGiZxdSZgosRw==}
@@ -2062,6 +2067,9 @@ packages:
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.
core-js@3.47.0:
resolution: {integrity: sha512-c3Q2VVkGAUyupsjRnaNX6u8Dq2vAdzm9iuPj5FW0fRxzlxgq9Q39MDq10IvmQSpLgHQNyQzQmOo6bgGHmH3NNg==}
core-util-is@1.0.3:
resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==}
@@ -2544,6 +2552,9 @@ packages:
picomatch:
optional: true
feather-icons@4.29.2:
resolution: {integrity: sha512-0TaCFTnBTVCz6U+baY2UJNKne5ifGh7sMG4ZC2LoBWCZdIyPa+y6UiR4lEYGws1JOFWdee8KAsAIvu0VcXqiqA==}
file-entry-cache@11.1.1:
resolution: {integrity: sha512-TPVFSDE7q91Dlk1xpFLvFllf8r0HyOMOlnWy7Z2HBku5H3KhIeOGInexrIeg2D64DosVB/JXkrrk6N/7Wriq4A==}
@@ -6397,6 +6408,8 @@ snapshots:
dependencies:
inherits: 2.0.4
classnames@2.5.1: {}
clean-up-path@1.0.0: {}
cliui@8.0.1:
@@ -6497,6 +6510,8 @@ snapshots:
core-js@2.6.12: {}
core-js@3.47.0: {}
core-util-is@1.0.3: {}
cors@2.8.5:
@@ -7206,6 +7221,11 @@ snapshots:
optionalDependencies:
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:
dependencies:
flat-cache: 6.1.19

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -6,8 +6,8 @@
<meta name="description" content="">
<meta name="viewport" content="width=device-width, initial-scale=1">
<script type="module" crossorigin src="/assets/main-PEcndiCZ.js"></script>
<link rel="stylesheet" crossorigin href="/assets/main-Dxork-AG.css">
<script type="module" crossorigin src="/assets/main-DQ03OXTu.js"></script>
<link rel="stylesheet" crossorigin href="/assets/main-B6U20YwQ.css">
</head>
<body>
</body>