Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
6b37508f66
|
|||
|
8106009677
|
|||
|
07489c43a4
|
|||
|
a4e375cb51
|
|||
|
b680769eac
|
|||
|
4a609c8388
|
|||
|
cfcaaea3ec
|
|||
|
2f440d4971
|
|||
|
1c6cbe6b0f
|
|||
|
bdd5db157c
|
|||
|
f7c40095d5
|
|||
|
579892067e
|
|||
|
48f87f98d6
|
@@ -1,156 +1,74 @@
|
||||
# Project Status: Marco
|
||||
|
||||
**Last Updated:** Tue Feb 24 2026
|
||||
**Last Updated:** Wed Mar 18 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.
|
||||
We are building **Marco**, a decentralized maps application using **Ember.js** (Octane/Polaris), **Vite**, and **OpenLayers**. The core feature is storing place bookmarks in **RemoteStorage.js**.
|
||||
|
||||
## 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.
|
||||
- **Logic Upgrade:** Map intelligently detects if _any_ sidebar/pane is open and handles outside clicks to close them instead of initiating new searches.
|
||||
- **Optimization:** Added **10px hit tolerance** for easier tapping on mobile devices.
|
||||
- **Visuals:** Increased bookmark marker size (Radius 9px) and added a subtle drop shadow.
|
||||
- **Feedback:** Implemented a "pulse" animation (via OpenLayers Overlay) at the click location to visualize the search radius (30m/50m).
|
||||
- **Mobile UX:**
|
||||
- **Touch:** Disabled browser tap highlights (`-webkit-tap-highlight-color: transparent`) to prevent blue flashing on Android.
|
||||
- **Scroll:** Disabled "pull-to-refresh" (`overscroll-behavior: none`) on the body to prevent accidental reloads while keeping the sidebar scrollable (`contain`).
|
||||
- **Auto-Pan:** On mobile screens, if a selected pin is obscured by the bottom sheet, the map automatically pans to center the pin in the visible top half of the screen.
|
||||
- **Controls:** Fixed positioning of "Locate" and "Rotate" buttons on mobile by correcting CSS `inset` syntax.
|
||||
- **iOS Polish:**
|
||||
- Prevented input auto-zoom by ensuring `.form-control` font size is `1rem` (16px).
|
||||
- Added `-webkit-text-size-adjust: 100%` to prevent text inflation on rotation.
|
||||
- Set base `body` font size to `16px`.
|
||||
- **Geolocation ("Locate Me"):**
|
||||
- Implemented a "Locate Me" button with robust tracking logic.
|
||||
- **Dynamic Zoom:** Automatically zooms to a level where the accuracy circle covers ~10% of the map (fallback logic handles missing accuracy data).
|
||||
- **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.
|
||||
- **Pin Animation:** Selected pins are highlighted with a custom **Red Pin** overlay that drops in with an animation. The center dot is styled as a solid dark red circle (`#b31412`).
|
||||
- **Smart Zoom:** Implemented `zoomToBbox` to automatically fit complex geometries (ways/relations) within the visible viewport.
|
||||
- **Dynamic Padding:** Calculates padding based on active UI elements (Sidebar on Desktop, Bottom Sheet on Mobile) to ensure the geometry is perfectly centered in the _visible_ map area.
|
||||
- **Data Processing:** `OsmService` now calculates bounding boxes for ways and relations by aggregating member node coordinates.
|
||||
- **Geometry Rendering:**
|
||||
- **Outlines:** Implemented distinct blue outlines for selected OSM `ways` (Polygons) and `relations` (MultiLineStrings/Polygons) to clearly visualize boundaries.
|
||||
- **Data Fetching:** Enhanced routing to fetch full geometry data on-demand if the initial search result (e.g., from Photon) lacks it, ensuring outlines are always available.
|
||||
- **Vector Tiles:** Using **OpenFreeMap Liberty** style with a hybrid click handler (Visual Tiles + Overpass API fallback).
|
||||
- **Smart Interaction:**
|
||||
- **Hit Tolerance:** 10px buffer for easier mobile tapping.
|
||||
- **Auto-Pan:** Selected pins automatically center in the visible area (respecting bottom sheets/sidebars).
|
||||
- **Smart Zoom:** `zoomToBbox` fits complex geometries (ways/relations) with dynamic padding, only zooming out to fit.
|
||||
- **Visuals:** Custom "Red Pin" overlay with drop animation. Selected OSM ways/relations show distinct blue outlines.
|
||||
- **Geolocation:** Robust "Locate Me" with dynamic zoom and accuracy visualization.
|
||||
|
||||
### 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:** Nested `<2-char>/<2-char>/<id>` (based on geohash) for scalability.
|
||||
- **API:**
|
||||
- `getPlaces(prefixes?)`: efficient partial loading of specific sectors (or full recursive scan if no prefixes provided).
|
||||
- Uses `getListing` for directory traversal and `getAll` for object retrieval.
|
||||
- configured with `maxAge: false` to ensure data freshness.
|
||||
- **Dependencies:** Uses `ulid` and `latlon-geohash` internally.
|
||||
- **Custom Module:** Handles `place` objects with Geohash-based partitioning (`<2-char>/<2-char>/<id>`).
|
||||
- **Optimization:** Supports efficient spatial querying via prefix loading.
|
||||
- **Lists Support:** Manages collection-based organization (e.g., "To Visit", "Favorites").
|
||||
|
||||
### 3. App Infrastructure & Build
|
||||
### 3. App Infrastructure
|
||||
|
||||
- **Services:**
|
||||
- `storage.js`: Initializes RemoteStorage, claims access, enables caching, and sets up the widget. Consumes the new `getPlaces` API.
|
||||
- **Optimization:** Implemented **Debounced Reload** (200ms) for bookmark updates to handle rapid change events efficiently.
|
||||
- **Optimization:** Correctly handles deletion/updates by clearing stale data for reloaded geohash sectors.
|
||||
- `osm.js`: Fetches nearby POIs from Overpass API.
|
||||
- **Configurable:** Now supports dynamic API endpoints via `SettingsService`.
|
||||
- **Reliability:** Implemented `fetchWithRetry` to handle HTTP 504/502/503 timeouts and 429 rate limits, in addition to network errors.
|
||||
- **Caching:** Implemented in-memory cache for repeated `getNearbyPois` requests (same lat/lon/radius) to enable instant "Back" navigation.
|
||||
- `settings.js`: Manages user preferences (currently Overpass API provider) persisted to `localStorage`.
|
||||
- `storage.js`: Manages RemoteStorage, caching, and the new **Lists** feature (`to-go`, `to-do`).
|
||||
- `osm.js`: Fetches/caches POIs from Overpass API (configurable endpoints).
|
||||
- `settings.js`: Persists user preferences (e.g., API provider).
|
||||
- **UI Components:**
|
||||
- `places-sidebar.gjs`: Displays a list of nearby POIs.
|
||||
- **Layout:** Responsive design that transforms into a **Bottom Sheet** (50% height) on mobile screens (`<=768px`) with rounded corners and upward shadow.
|
||||
- `place-details.gjs`: Dedicated component for displaying rich place information.
|
||||
- **Features:** Icons (via `feather-icons`), Address, Phone, Website, Opening Hours, Cuisine, Wikipedia.
|
||||
- **Layout:** Polished UI with distinct sections for Actions and Meta info.
|
||||
- `app-header.gjs`: Transparent header with "Menu" button (Settings) and User Avatar (Login).
|
||||
- `settings-pane.gjs`: Sidebar component for app info ("About" section) and settings.
|
||||
- **Features:** Dropdown to select Overpass API provider (bke.ro, overpass-api.de, private.coffee).
|
||||
- **Mobile:** Renders as a 2/3 height bottom sheet on mobile.
|
||||
- **Z-Index:** Configured to overlay the Places sidebar correctly (`z-index: 3200`).
|
||||
- **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.
|
||||
- **Format Utils:**
|
||||
- `app/utils/format-text.js` & `humanize-osm-tag` helper: Standardized logic (Title Case, space replacement) for displaying OSM tags like `guest_house` -> "Guest House".
|
||||
- **Tag refinement:** Improved logic for handling generic tags (e.g., `building=yes`). The UI now intelligently displays the key ("Building") instead of the value ("Yes") for better readability.
|
||||
- **Localization:** Added basic `navigator.languages` support to `getLocalizedName` for preferring local names when available.
|
||||
- **Build & DevOps:**
|
||||
- **Icon Generation:** Added `build:icons` script using `magick` and `rsvg-convert` to automate PNG generation from SVG.
|
||||
- **Dependencies:** Documented system requirements (ImageMagick, librsvg) in `README.md`.
|
||||
- **Ember CLI:** Added as dev dependency to support generator commands.
|
||||
- **License:** Added AGPLv3 license.
|
||||
- **Responsive Layout:** Sidebar transforms into a Bottom Sheet on mobile.
|
||||
- **Place Details:** Rich info (Address, Socials, Opening Hours) with distinct "Actions" and "Meta" sections.
|
||||
- **App Menu:** Comprehensive settings and about section, implemented as a secondary sidebar.
|
||||
- **CI/CD:** Gitea Actions for automated testing and release drafting.
|
||||
|
||||
### 4. Routing & Architecture (Refactored)
|
||||
### 4. Routing & Architecture
|
||||
|
||||
- **URL-Driven Architecture:** Moved from service-based state to proper route-based state management.
|
||||
- `/search?lat=...&lon=...&q=...`: Displays search results list.
|
||||
- `/place/:place_id`: Displays details for a specific place (OSM POI or Bookmark).
|
||||
- **Heuristic Navigation:** The `search` route implements "visual click matching" logic. If a search yields a direct match (exact name or very close proximity), it automatically redirects to the `/place/` route, skipping the list view.
|
||||
- **Back Button Support:** Browser history works correctly. Navigating "Back" from a place returns to the cached search results instantly without network requests.
|
||||
- **Explicit URLs:** Routes support specific OSM entities via `/place/osm:node:<id>` and `/place/osm:way:<id>`, distinguishing them from local bookmarks (ULIDs).
|
||||
- **Smart Linking:** The `showPlaces` action intercepts search results and automatically resolves them to existing **Bookmarks** if a match is found (via `storage.findPlaceById`). This ensures the app navigates to the persistent Bookmark URL (ULID) and correctly reflects the "Saved" status in the UI instead of treating it as a new generic OSM place.
|
||||
- **Data Normalization:** Refactored `OsmService` to return normalized objects (`osmTags`, `osmType`) for all queries. This ensures consistent data structures between fresh Overpass results and saved bookmarks throughout the app.
|
||||
- **URL-Driven:** `/search` (list) and `/place/:id` (details) routes.
|
||||
- **Smart Navigation:**
|
||||
- Direct hits redirect to details.
|
||||
- Search results automatically resolve to existing **Bookmarks**.
|
||||
- "Back" navigation returns to cached search results instantly.
|
||||
|
||||
### 5. Creation & Editing Workflow
|
||||
### 5. Features
|
||||
|
||||
- **Create Place:**
|
||||
- Implemented `/place/new` route for creating new private places.
|
||||
- **UX:** Map displays a central crosshair for precise location selection.
|
||||
- **Mobile Optimization:**
|
||||
- Disabled map inertia (`kinetic: false`) to ensure the map stops exactly where the finger releases.
|
||||
- `PlaceEditForm` conditionally disables autofocus on mobile screens (`<= 768px`) to prevent the onscreen keyboard from obscuring the map view immediately.
|
||||
- Responsive crosshair sizing (48px desktop / 24px mobile).
|
||||
- **Persistence:** Form data (Title, Description) and Map coordinates are securely saved to RemoteStorage via `storage.storePlace`.
|
||||
|
||||
### 6. Search Functionality
|
||||
|
||||
- **Provider:** Integrated **Photon API** (by Komoot) via `app/services/photon.js` for high-quality, typo-tolerant OpenStreetMap search.
|
||||
- **UI:** `SearchBoxComponent` implements a responsive search bar with instant autocomplete.
|
||||
- **Debounced Input:** 300ms delay to prevent excessive API calls.
|
||||
- **Location Bias:** Automatically biases search results towards the current map center to show relevant local places first.
|
||||
- **Direct Navigation:** Selecting a result with a valid OSM ID navigates directly to the specific place details (`/place/osm:type:id`).
|
||||
- **Resilience:** Implemented retry logic (exponential backoff/fixed delay) for network errors and rate limits (429).
|
||||
- **Data Normalization:** Search results are normalized to match the internal POI schema, ensuring consistent rendering across Search and Map views.
|
||||
- **Search:** Typo-tolerant **Photon API** integration with location bias and debounce.
|
||||
- **Creation & Editing:**
|
||||
- "Crosshair" mode for precise location picking.
|
||||
- Edit Title/Description for saved places.
|
||||
- **Lists:** Users can add places to default lists ("To Go", "To Do") directly from the details view.
|
||||
- **Socials:** Place details now include Email, Facebook, and Instagram links.
|
||||
- **Data Sync:** Auto-refreshes OSM data (coords/tags) for saved places on view, preserving custom titles.
|
||||
|
||||
## Current State
|
||||
|
||||
- **Repo:** The app runs via `pnpm start`.
|
||||
- **Repo:** Runs via `pnpm start`.
|
||||
- **Workflow:**
|
||||
1. User pans map -> `moveend` triggers `storage.loadPlacesInBounds`.
|
||||
2. User clicks map -> Route transition to `/search` -> "Pulse" animation -> hybrid hit detection (Visual Tile vs Overpass).
|
||||
3. **Navigation:**
|
||||
- If direct match: Redirect to `/place/:id`.
|
||||
- If multiple results: Show `/search` list view.
|
||||
4. Sidebar displays details via `<PlaceDetails>` component (Bottom sheet on mobile).
|
||||
5. **Creation:** User clicks "Create Place" -> Enters creation mode (crosshair) -> Positions map -> Enters details -> Save.
|
||||
6. **Persistence:** RemoteStorage change event -> Debounced reload updates the map reactive-ly.
|
||||
7. **Editing:** User can edit the Title and Description of saved bookmarks via an "Edit" button in the details view.
|
||||
8. **Settings:** User can change the Overpass API provider via the new Settings menu.
|
||||
1. **Explore:** Pan/Zoom loads bookmarks from RemoteStorage.
|
||||
2. **Search:** Query via Photon -> List or Direct Result.
|
||||
3. **View:** Details pane (Sidebar/Bottom Sheet) shows rich info + social links.
|
||||
4. **Action:**
|
||||
- **Save:** Persist to RemoteStorage.
|
||||
- **Organize:** Add to "To Go" / "To Do" lists.
|
||||
- **Edit:** Custom Title/Description.
|
||||
5. **Sync:** Background check updates OSM data if changed.
|
||||
|
||||
## Files Currently in Focus
|
||||
## Next Steps
|
||||
|
||||
- `app/services/osm.js`
|
||||
- `app/components/map.gjs`
|
||||
- `app/routes/place.js`
|
||||
- `app/utils/osm.js`
|
||||
|
||||
## Next Steps & Pending Tasks
|
||||
|
||||
1. **Linting & Code Quality:** Fix remaining CSS errors and address unused variables/runloop usage.
|
||||
2. **Testing:** Add automated tests for the geohash coverage, retry logic, and new editing features.
|
||||
3. **Performance:** Monitor performance with large datasets (thousands of bookmarks).
|
||||
|
||||
## 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`.
|
||||
1. **Testing:** Add automated tests for the new Lists logic and Geohash coverage.
|
||||
2. **Performance:** Monitor with large datasets.
|
||||
3. **Refinement:** Polish list UI and interactions.
|
||||
|
||||
@@ -111,6 +111,22 @@ import Icon from '#components/icon';
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
<a href="https://pinhead.ink/" target="_blank" rel="noopener">
|
||||
Pinhead Icons
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<a
|
||||
href="https://github.com/waysidemapping/pinhead?tab=readme-ov-file#where-the-icons-are-from"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>
|
||||
Various
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
@@ -305,6 +305,7 @@ export default class PlaceDetails extends Component {
|
||||
type="button"
|
||||
class="btn btn-outline"
|
||||
title="Edit"
|
||||
disabled={{this.isEditing}}
|
||||
{{on "click" this.startEditing}}
|
||||
>
|
||||
<Icon @name="edit" @color="var(--link-color)" />
|
||||
@@ -316,9 +317,11 @@ export default class PlaceDetails extends Component {
|
||||
<div class="meta-info">
|
||||
|
||||
{{#if this.cuisine}}
|
||||
<p class="cuisine-info">
|
||||
<strong>Cuisine:</strong>
|
||||
{{this.cuisine}}
|
||||
<p class="content-with-icon">
|
||||
<Icon @name="fork-and-knife" @title="Cuisine" @filled={{true}} />
|
||||
<span>
|
||||
{{this.cuisine}}
|
||||
</span>
|
||||
</p>
|
||||
{{/if}}
|
||||
|
||||
|
||||
@@ -101,6 +101,23 @@ export default class PlaceRoute extends Route {
|
||||
return null;
|
||||
}
|
||||
|
||||
setupController(controller, model) {
|
||||
super.setupController(controller, model);
|
||||
this.checkUpdates(model);
|
||||
}
|
||||
|
||||
async checkUpdates(place) {
|
||||
// Only check for updates if it's a saved place (has ID) and is an OSM object
|
||||
if (place && place.id && place.osmId && place.osmType) {
|
||||
const updatedPlace = await this.storage.refreshPlace(place);
|
||||
if (updatedPlace) {
|
||||
// If an update occurred, refresh the map UI selection without moving the camera
|
||||
// This ensures the sidebar shows the new data
|
||||
this.mapUi.selectPlace(updatedPlace, { preventZoom: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
serialize(model) {
|
||||
// If the model is a saved bookmark, use its ID
|
||||
if (model.id) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import Service from '@ember/service';
|
||||
import Service, { service } from '@ember/service';
|
||||
import RemoteStorage from 'remotestoragejs';
|
||||
import Places from '@remotestorage/module-places';
|
||||
import Widget from 'remotestorage-widget';
|
||||
@@ -7,8 +7,10 @@ import { getGeohashPrefixesInBbox } from '../utils/geohash-coverage';
|
||||
import { action } from '@ember/object';
|
||||
import { debounceTask } from 'ember-lifeline';
|
||||
import Geohash from 'latlon-geohash';
|
||||
import { getLocalizedName } from '../utils/osm';
|
||||
|
||||
export default class StorageService extends Service {
|
||||
@service osm;
|
||||
rs;
|
||||
widget;
|
||||
@tracked placesInView = [];
|
||||
@@ -366,6 +368,82 @@ export default class StorageService extends Service {
|
||||
}
|
||||
}
|
||||
|
||||
async refreshPlace(place) {
|
||||
if (!place || !place.id || !place.osmId || !place.osmType) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
console.debug(`Checking for updates for ${place.title} (${place.osmId})`);
|
||||
const freshData = await this.osm.fetchOsmObject(
|
||||
place.osmId,
|
||||
place.osmType
|
||||
);
|
||||
|
||||
if (!freshData) {
|
||||
console.warn('Could not fetch fresh data for', place.osmId);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check for changes
|
||||
let hasChanges = false;
|
||||
const changes = {};
|
||||
|
||||
// 1. Check Coordinates (allow tiny drift < ~1m)
|
||||
const latDiff = Math.abs(place.lat - freshData.lat);
|
||||
const lonDiff = Math.abs(place.lon - freshData.lon);
|
||||
if (latDiff > 0.00001 || lonDiff > 0.00001) {
|
||||
hasChanges = true;
|
||||
changes.lat = freshData.lat;
|
||||
changes.lon = freshData.lon;
|
||||
}
|
||||
|
||||
// 2. Check Tags
|
||||
const oldTags = place.osmTags || {};
|
||||
const newTags = freshData.osmTags || {};
|
||||
const allKeys = new Set([
|
||||
...Object.keys(oldTags),
|
||||
...Object.keys(newTags),
|
||||
]);
|
||||
|
||||
for (const key of allKeys) {
|
||||
if (oldTags[key] !== newTags[key]) {
|
||||
hasChanges = true;
|
||||
changes.osmTags = newTags;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasChanges) {
|
||||
console.debug('No changes detected for', place.title);
|
||||
return null;
|
||||
}
|
||||
|
||||
console.debug('Changes detected:', changes);
|
||||
|
||||
// 3. Prepare Update
|
||||
const updatedPlace = {
|
||||
...place,
|
||||
...changes,
|
||||
};
|
||||
|
||||
// If the current title matches the old localized name, update it to the
|
||||
// new localized name. If the user renamed it (custom title), keep it.
|
||||
const oldDefaultName = getLocalizedName(oldTags);
|
||||
const newDefaultName = getLocalizedName(newTags);
|
||||
|
||||
if (place.title === oldDefaultName && oldDefaultName !== newDefaultName) {
|
||||
updatedPlace.title = newDefaultName;
|
||||
}
|
||||
|
||||
// 4. Save
|
||||
return await this.updatePlace(updatedPlace);
|
||||
} catch (e) {
|
||||
console.error('Failed to refresh place:', e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@action
|
||||
connect() {
|
||||
this.isWidgetOpen = true;
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
:root {
|
||||
--default-list-color: #fc3;
|
||||
--hover-bg: #f8f9fa;
|
||||
--sidebar-width: 360px;
|
||||
--sidebar-width: 350px;
|
||||
--link-color: #2a7fff;
|
||||
--link-color-visited: #6a4fbf;
|
||||
}
|
||||
@@ -344,11 +344,6 @@ body {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.sidebar-content details .link-list {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@keyframes details-slide-down {
|
||||
from {
|
||||
opacity: 0;
|
||||
@@ -361,14 +356,6 @@ body {
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar-content details .link-list li {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.sidebar-content details .link-list li:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.edit-form {
|
||||
margin: -1rem;
|
||||
margin-bottom: 1rem;
|
||||
@@ -521,26 +508,6 @@ abbr[title] {
|
||||
text-decoration: underline dotted;
|
||||
}
|
||||
|
||||
.link-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.link-list li {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.link-list a {
|
||||
color: var(--link-color);
|
||||
text-decoration: none;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.link-list a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.places-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
@@ -636,6 +603,12 @@ abbr[title] {
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.btn-outline {
|
||||
background: transparent;
|
||||
color: #333;
|
||||
|
||||
@@ -28,6 +28,7 @@ import user from 'feather-icons/dist/icons/user.svg?raw';
|
||||
import x from 'feather-icons/dist/icons/x.svg?raw';
|
||||
import zap from 'feather-icons/dist/icons/zap.svg?raw';
|
||||
import wikipedia from '../icons/wikipedia.svg?raw';
|
||||
import forkAndKnife from '@waysidemapping/pinhead/dist/icons/fork_and_knife.svg?raw';
|
||||
|
||||
const ICONS = {
|
||||
'arrow-left': arrowLeft,
|
||||
@@ -43,6 +44,7 @@ const ICONS = {
|
||||
home,
|
||||
info,
|
||||
instagram,
|
||||
'fork-and-knife': forkAndKnife,
|
||||
'log-in': logIn,
|
||||
'log-out': logOut,
|
||||
mail,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "marco",
|
||||
"version": "1.15.3",
|
||||
"version": "1.16.0",
|
||||
"private": true,
|
||||
"description": "Unhosted maps app",
|
||||
"repository": {
|
||||
@@ -102,6 +102,7 @@
|
||||
"edition": "octane"
|
||||
},
|
||||
"dependencies": {
|
||||
"@waysidemapping/pinhead": "^15.17.0",
|
||||
"ember-concurrency": "^5.2.0",
|
||||
"ember-lifeline": "^7.0.0"
|
||||
}
|
||||
|
||||
8
pnpm-lock.yaml
generated
8
pnpm-lock.yaml
generated
@@ -8,6 +8,9 @@ importers:
|
||||
|
||||
.:
|
||||
dependencies:
|
||||
'@waysidemapping/pinhead':
|
||||
specifier: ^15.17.0
|
||||
version: 15.17.0
|
||||
ember-concurrency:
|
||||
specifier: ^5.2.0
|
||||
version: 5.2.0(@babel/core@7.28.6)
|
||||
@@ -1651,6 +1654,9 @@ packages:
|
||||
peerDependencies:
|
||||
'@warp-drive/core': 5.8.1
|
||||
|
||||
'@waysidemapping/pinhead@15.17.0':
|
||||
resolution: {integrity: sha512-XcL/0Ll+gkRIpXlO+skwd6USynA+mX3DNwqrWDMhgRmLP4DNRPTeaecK64BBxk1bB/F9Xi/9kgN6JA5zbdgejQ==}
|
||||
|
||||
'@xmldom/xmldom@0.8.11':
|
||||
resolution: {integrity: sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw==}
|
||||
engines: {node: '>=10.0.0'}
|
||||
@@ -7239,6 +7245,8 @@ snapshots:
|
||||
- '@glint/template'
|
||||
- supports-color
|
||||
|
||||
'@waysidemapping/pinhead@15.17.0': {}
|
||||
|
||||
'@xmldom/xmldom@0.8.11': {}
|
||||
|
||||
abbrev@1.1.1: {}
|
||||
|
||||
1
release/assets/main-BOfcjRke.css
Normal file
1
release/assets/main-BOfcjRke.css
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -39,8 +39,8 @@
|
||||
<meta name="msapplication-TileColor" content="#F6E9A6">
|
||||
<meta name="msapplication-TileImage" content="/icons/icon-144.png">
|
||||
|
||||
<script type="module" crossorigin src="/assets/main-DMekdiSt.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/main-CZskVGin.css">
|
||||
<script type="module" crossorigin src="/assets/main-gEUnNw-L.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/main-BOfcjRke.css">
|
||||
</head>
|
||||
<body>
|
||||
</body>
|
||||
|
||||
@@ -125,4 +125,60 @@ module('Unit | Route | place', function (hooks) {
|
||||
|
||||
assert.notOk(fetchCalled, 'fetchOsmObject should NOT be called for nodes');
|
||||
});
|
||||
|
||||
test('setupController triggers checkUpdates', async function (assert) {
|
||||
let route = this.owner.lookup('route:place');
|
||||
|
||||
// Stub Storage Service
|
||||
let refreshPlaceCalled = false;
|
||||
class StorageStub extends Service {
|
||||
async refreshPlace(place) {
|
||||
refreshPlaceCalled = true;
|
||||
assert.strictEqual(place.id, '123', 'Passed correct place to storage');
|
||||
return {
|
||||
...place,
|
||||
title: 'Updated Title',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Stub MapUi Service
|
||||
let selectPlaceCalled = false;
|
||||
class MapUiStub extends Service {
|
||||
selectPlace(place, options) {
|
||||
selectPlaceCalled = true;
|
||||
assert.strictEqual(
|
||||
place.title,
|
||||
'Updated Title',
|
||||
'Selected updated place'
|
||||
);
|
||||
assert.ok(options.preventZoom, 'Prevented zoom on update');
|
||||
}
|
||||
stopSearch() {}
|
||||
}
|
||||
|
||||
this.owner.register('service:storage', StorageStub);
|
||||
this.owner.register('service:map-ui', MapUiStub);
|
||||
|
||||
let model = {
|
||||
id: '123',
|
||||
osmId: '456',
|
||||
osmType: 'node',
|
||||
title: 'Original Title',
|
||||
};
|
||||
|
||||
let controller = {};
|
||||
|
||||
// Trigger setupController
|
||||
route.setupController(controller, model);
|
||||
|
||||
// checkUpdates is async and not awaited in setupController, so we need to wait a tick
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
|
||||
assert.ok(refreshPlaceCalled, 'refreshPlace should be called');
|
||||
assert.ok(
|
||||
selectPlaceCalled,
|
||||
'mapUi.selectPlace should be called with update'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
202
tests/unit/services/storage-test.js
Normal file
202
tests/unit/services/storage-test.js
Normal file
@@ -0,0 +1,202 @@
|
||||
import { module, test } from 'qunit';
|
||||
import { setupTest } from 'marco/tests/helpers';
|
||||
import Service from '@ember/service';
|
||||
|
||||
module('Unit | Service | storage', function (hooks) {
|
||||
setupTest(hooks);
|
||||
|
||||
test('refreshPlace skips invalid places', async function (assert) {
|
||||
let service = this.owner.lookup('service:storage');
|
||||
let result = await service.refreshPlace({});
|
||||
assert.strictEqual(result, null);
|
||||
});
|
||||
|
||||
test('refreshPlace detects coordinate drift', async function (assert) {
|
||||
let service = this.owner.lookup('service:storage');
|
||||
|
||||
// Stub OSM Service
|
||||
class OsmStub extends Service {
|
||||
async fetchOsmObject(id, type) {
|
||||
return {
|
||||
osmId: id,
|
||||
osmType: type,
|
||||
lat: 52.5201, // Changed significantly from 52.5200
|
||||
lon: 13.405,
|
||||
osmTags: { name: 'Foo' },
|
||||
};
|
||||
}
|
||||
}
|
||||
this.owner.register('service:osm', OsmStub);
|
||||
|
||||
// Mock storage update
|
||||
let updatePlaceCalled = false;
|
||||
service.updatePlace = async (place) => {
|
||||
updatePlaceCalled = true;
|
||||
return place;
|
||||
};
|
||||
|
||||
let place = {
|
||||
id: '123',
|
||||
osmId: '456',
|
||||
osmType: 'node',
|
||||
lat: 52.52,
|
||||
lon: 13.405,
|
||||
osmTags: { name: 'Foo' },
|
||||
title: 'Foo',
|
||||
};
|
||||
|
||||
let result = await service.refreshPlace(place);
|
||||
|
||||
assert.ok(updatePlaceCalled, 'updatePlace should be called');
|
||||
assert.strictEqual(result.lat, 52.5201, 'Latitude updated');
|
||||
});
|
||||
|
||||
test('refreshPlace ignores tiny coordinate drift', async function (assert) {
|
||||
let service = this.owner.lookup('service:storage');
|
||||
|
||||
class OsmStub extends Service {
|
||||
async fetchOsmObject(id, type) {
|
||||
return {
|
||||
osmId: id,
|
||||
osmType: type,
|
||||
lat: 52.5200005, // Tiny change (< 0.00001)
|
||||
lon: 13.405,
|
||||
osmTags: { name: 'Foo' },
|
||||
};
|
||||
}
|
||||
}
|
||||
this.owner.register('service:osm', OsmStub);
|
||||
|
||||
let updatePlaceCalled = false;
|
||||
service.updatePlace = async () => {
|
||||
updatePlaceCalled = true;
|
||||
};
|
||||
|
||||
let place = {
|
||||
id: '123',
|
||||
osmId: '456',
|
||||
osmType: 'node',
|
||||
lat: 52.52,
|
||||
lon: 13.405,
|
||||
osmTags: { name: 'Foo' },
|
||||
title: 'Foo',
|
||||
};
|
||||
|
||||
await service.refreshPlace(place);
|
||||
|
||||
assert.notOk(updatePlaceCalled, 'updatePlace should NOT be called');
|
||||
});
|
||||
|
||||
test('refreshPlace detects tag changes', async function (assert) {
|
||||
let service = this.owner.lookup('service:storage');
|
||||
|
||||
class OsmStub extends Service {
|
||||
async fetchOsmObject(id, type) {
|
||||
return {
|
||||
osmId: id,
|
||||
osmType: type,
|
||||
lat: 52.52,
|
||||
lon: 13.405,
|
||||
osmTags: { name: 'Bar' }, // Changed name
|
||||
};
|
||||
}
|
||||
}
|
||||
this.owner.register('service:osm', OsmStub);
|
||||
|
||||
let updatePlaceCalled = false;
|
||||
service.updatePlace = async (place) => {
|
||||
updatePlaceCalled = true;
|
||||
return place;
|
||||
};
|
||||
|
||||
let place = {
|
||||
id: '123',
|
||||
osmId: '456',
|
||||
osmType: 'node',
|
||||
lat: 52.52,
|
||||
lon: 13.405,
|
||||
osmTags: { name: 'Foo' },
|
||||
title: 'Foo',
|
||||
};
|
||||
|
||||
let result = await service.refreshPlace(place);
|
||||
|
||||
assert.ok(updatePlaceCalled, 'updatePlace should be called');
|
||||
assert.strictEqual(result.osmTags.name, 'Bar', 'Tags updated');
|
||||
});
|
||||
|
||||
test('refreshPlace updates title if it was default', async function (assert) {
|
||||
let service = this.owner.lookup('service:storage');
|
||||
|
||||
class OsmStub extends Service {
|
||||
async fetchOsmObject(id, type) {
|
||||
return {
|
||||
osmId: id,
|
||||
osmType: type,
|
||||
lat: 52.52,
|
||||
lon: 13.405,
|
||||
osmTags: { name: 'New Name' },
|
||||
};
|
||||
}
|
||||
}
|
||||
this.owner.register('service:osm', OsmStub);
|
||||
|
||||
service.updatePlace = async (place) => place;
|
||||
|
||||
let place = {
|
||||
id: '123',
|
||||
osmId: '456',
|
||||
osmType: 'node',
|
||||
lat: 52.52,
|
||||
lon: 13.405,
|
||||
osmTags: { name: 'Old Name' },
|
||||
title: 'Old Name', // Matches default
|
||||
};
|
||||
|
||||
let result = await service.refreshPlace(place);
|
||||
|
||||
assert.strictEqual(result.title, 'New Name', 'Title should update');
|
||||
});
|
||||
|
||||
test('refreshPlace preserves custom title', async function (assert) {
|
||||
let service = this.owner.lookup('service:storage');
|
||||
|
||||
class OsmStub extends Service {
|
||||
async fetchOsmObject(id, type) {
|
||||
return {
|
||||
osmId: id,
|
||||
osmType: type,
|
||||
lat: 52.52,
|
||||
lon: 13.405,
|
||||
osmTags: { name: 'New Name' },
|
||||
};
|
||||
}
|
||||
}
|
||||
this.owner.register('service:osm', OsmStub);
|
||||
|
||||
service.updatePlace = async (place) => place;
|
||||
|
||||
let place = {
|
||||
id: '123',
|
||||
osmId: '456',
|
||||
osmType: 'node',
|
||||
lat: 52.52,
|
||||
lon: 13.405,
|
||||
osmTags: { name: 'Old Name' },
|
||||
title: 'My Custom Place', // User renamed it
|
||||
};
|
||||
|
||||
let result = await service.refreshPlace(place);
|
||||
|
||||
assert.strictEqual(
|
||||
result.title,
|
||||
'My Custom Place',
|
||||
'Title should NOT update'
|
||||
);
|
||||
assert.strictEqual(
|
||||
result.osmTags.name,
|
||||
'New Name',
|
||||
'Tags should still update'
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user