Compare commits
21 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
e7b3b72e2f
|
|||
|
399ad1822d
|
|||
|
104a742543
|
|||
|
a8dc4c81e4
|
|||
|
156280950f
|
|||
|
41d61be42e
|
|||
|
06b47d96a7
|
|||
|
e8af959be6
|
|||
|
254e177cbf
|
|||
|
47fbc8e7cf
|
|||
|
4ad0df22e2
|
|||
|
0decb4cf1b
|
|||
|
2193f935cc
|
|||
|
b2b03c0a38
|
|||
|
0be02c5b20
|
|||
|
653e44348c
|
|||
|
8fdc697a17
|
|||
|
d9b2a17b91
|
|||
|
85255318ba
|
|||
|
713d9d53e6
|
|||
|
e0ea0ca988
|
@@ -1,6 +1,6 @@
|
||||
# Project Status: Marco
|
||||
|
||||
**Last Updated:** Sat Jan 24 2026
|
||||
**Last Updated:** Mon Jan 26 2026
|
||||
|
||||
## Project Context
|
||||
|
||||
@@ -21,9 +21,14 @@ We are building **Marco**, a decentralized maps application using **Ember.js** (
|
||||
- **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:**
|
||||
- Disabled browser tap highlights (`-webkit-tap-highlight-color: transparent`) to prevent blue flashing on Android.
|
||||
- Disabled "pull-to-refresh" (`overscroll-behavior: none`) on the body to prevent accidental reloads while keeping the sidebar scrollable (`contain`).
|
||||
- **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).
|
||||
@@ -44,7 +49,7 @@ We are building **Marco**, a decentralized maps application using **Ember.js** (
|
||||
- configured with `maxAge: false` to ensure data freshness.
|
||||
- **Dependencies:** Uses `ulid` and `latlon-geohash` internally.
|
||||
|
||||
### 3. App Infrastructure
|
||||
### 3. App Infrastructure & Build
|
||||
|
||||
- **Services:**
|
||||
- `storage.js`: Initializes RemoteStorage, claims access, enables caching, and sets up the widget. Consumes the new `getPlaces` API.
|
||||
@@ -68,6 +73,11 @@ We are building **Marco**, a decentralized maps application using **Ember.js** (
|
||||
- **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.
|
||||
- **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.
|
||||
|
||||
### 4. Routing & Data Optimization
|
||||
|
||||
@@ -91,16 +101,17 @@ We are building **Marco**, a decentralized maps application using **Ember.js** (
|
||||
|
||||
## Files Currently in Focus
|
||||
|
||||
- `app/templates/application.gjs`: Core layout and "Outside Click" logic.
|
||||
- `app/components/settings-pane.gjs`: Settings UI.
|
||||
- `app/services/settings.js`: Settings persistence.
|
||||
- `app/styles/app.css`: Mobile CSS fixes (font sizes, control positioning).
|
||||
- `package.json`: New scripts and dependencies.
|
||||
- `README.md`: Updated documentation.
|
||||
|
||||
## Next Steps & Pending Tasks
|
||||
|
||||
1. **Collections/Lists:** Implement ability to organize bookmarks into lists/collections.
|
||||
2. **Linting & Code Quality:** Fix remaining CSS errors, remove inline styles in `map.gjs`, and address unused variables/runloop usage.
|
||||
3. **Performance:** Monitor performance with large datasets (thousands of bookmarks).
|
||||
4. **Testing:** Add automated tests for the geohash coverage, retry logic, and new editing features.
|
||||
1. **Mobile Polish:** Verify "Locate Me" animation on iOS Safari.
|
||||
2. **Collections/Lists:** Implement ability to organize bookmarks into lists/collections.
|
||||
3. **Linting & Code Quality:** Fix remaining CSS errors, remove inline styles in `map.gjs`, and address unused variables/runloop usage.
|
||||
4. **Performance:** Monitor performance with large datasets (thousands of bookmarks).
|
||||
5. **Testing:** Add automated tests for the geohash coverage, retry logic, and new editing features.
|
||||
|
||||
## Technical Constraints
|
||||
|
||||
|
||||
@@ -73,6 +73,7 @@ To run the script, you need `imagemagick` and `librsvg` installed:
|
||||
|
||||
- [ember.js](https://emberjs.com/)
|
||||
- [remoteStorage.js](https://remotestorage.io/rs.js/docs/)
|
||||
- [@remotestorage/module-places](https://gitea.kosmos.org/raucao/remotestorage-module-places)
|
||||
- [Vite](https://vite.dev)
|
||||
- Development Browser Extensions
|
||||
- [ember inspector for chrome](https://chrome.google.com/webstore/detail/ember-inspector/bmdblncegkenkacieihfhpjfppoconhi)
|
||||
|
||||
@@ -17,6 +17,7 @@ import navigation from 'feather-icons/dist/icons/navigation.svg?raw';
|
||||
import phone from 'feather-icons/dist/icons/phone.svg?raw';
|
||||
import server from 'feather-icons/dist/icons/server.svg?raw';
|
||||
import settings from 'feather-icons/dist/icons/settings.svg?raw';
|
||||
import target from 'feather-icons/dist/icons/target.svg?raw';
|
||||
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';
|
||||
@@ -38,6 +39,7 @@ const ICONS = {
|
||||
phone,
|
||||
server,
|
||||
settings,
|
||||
target,
|
||||
user,
|
||||
x,
|
||||
zap,
|
||||
|
||||
@@ -21,6 +21,7 @@ export default class MapComponent extends Component {
|
||||
@service osm;
|
||||
@service storage;
|
||||
@service mapUi;
|
||||
@service router;
|
||||
|
||||
mapInstance;
|
||||
bookmarkSource;
|
||||
@@ -61,8 +62,8 @@ export default class MapComponent extends Component {
|
||||
});
|
||||
|
||||
// Default view settings
|
||||
let center = [99.05738, 7.55087];
|
||||
let zoom = 13.0;
|
||||
let center = [14.21683569, 27.060114248];
|
||||
let zoom = 2.661;
|
||||
|
||||
// Try to restore from localStorage
|
||||
try {
|
||||
@@ -510,6 +511,17 @@ export default class MapComponent extends Component {
|
||||
}
|
||||
}
|
||||
|
||||
// Sync the pulse animation with the UI service state
|
||||
syncPulse = modifier(() => {
|
||||
if (!this.searchOverlayElement) return;
|
||||
|
||||
if (this.mapUi.isSearching) {
|
||||
this.searchOverlayElement.classList.add('active');
|
||||
} else {
|
||||
this.searchOverlayElement.classList.remove('active');
|
||||
}
|
||||
});
|
||||
|
||||
handleMapMove = async () => {
|
||||
if (!this.mapInstance) return;
|
||||
|
||||
@@ -546,7 +558,6 @@ export default class MapComponent extends Component {
|
||||
});
|
||||
let clickedBookmark = null;
|
||||
let selectedFeatureName = null;
|
||||
let selectedFeatureType = null;
|
||||
|
||||
if (features && features.length > 0) {
|
||||
console.debug(`Found ${features.length} features in map layer:`);
|
||||
@@ -561,7 +572,6 @@ export default class MapComponent extends Component {
|
||||
const props = features[0].getProperties();
|
||||
if (props.name) {
|
||||
selectedFeatureName = props.name;
|
||||
selectedFeatureType = props.class || props.subclass;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -573,9 +583,7 @@ export default class MapComponent extends Component {
|
||||
'Clicked bookmark while sidebar open (switching):',
|
||||
clickedBookmark
|
||||
);
|
||||
if (this.args.onPlacesFound) {
|
||||
this.args.onPlacesFound([], clickedBookmark);
|
||||
}
|
||||
this.router.transitionTo('place', clickedBookmark);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -589,9 +597,7 @@ export default class MapComponent extends Component {
|
||||
// Normal behavior (sidebar is closed)
|
||||
if (clickedBookmark) {
|
||||
console.log('Clicked bookmark:', clickedBookmark);
|
||||
if (this.args.onPlacesFound) {
|
||||
this.args.onPlacesFound([], clickedBookmark);
|
||||
}
|
||||
this.router.transitionTo('place', clickedBookmark);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -615,76 +621,21 @@ export default class MapComponent extends Component {
|
||||
this.searchOverlayElement.style.width = `${diameterInPixels}px`;
|
||||
this.searchOverlayElement.style.height = `${diameterInPixels}px`;
|
||||
this.searchOverlay.setPosition(event.coordinate);
|
||||
this.searchOverlayElement.classList.add('active');
|
||||
}
|
||||
|
||||
// 2. Fetch authoritative data via Overpass
|
||||
try {
|
||||
let pois = await this.osm.getNearbyPois(lat, lon, searchRadius);
|
||||
// Start Search State
|
||||
this.mapUi.startSearch();
|
||||
|
||||
// Sort by distance from click
|
||||
pois = pois
|
||||
.map((p) => {
|
||||
// p is already normalized by service, so lat/lon are at top level
|
||||
return {
|
||||
...p,
|
||||
_distance: getDistance(lat, lon, p.lat, p.lon),
|
||||
};
|
||||
})
|
||||
.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.osmTags &&
|
||||
(p.osmTags.name === selectedFeatureName ||
|
||||
p.osmTags['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.osmTags.amenity ||
|
||||
topCandidate.osmTags.shop ||
|
||||
topCandidate.osmTags.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);
|
||||
} finally {
|
||||
if (this.searchOverlayElement) {
|
||||
this.searchOverlayElement.classList.remove('active');
|
||||
}
|
||||
// Transition to Search Route
|
||||
const queryParams = {
|
||||
lat: lat.toFixed(6),
|
||||
lon: lon.toFixed(6),
|
||||
};
|
||||
if (selectedFeatureName) {
|
||||
queryParams.q = selectedFeatureName;
|
||||
}
|
||||
|
||||
this.router.transitionTo('search', { queryParams });
|
||||
};
|
||||
|
||||
<template>
|
||||
@@ -693,6 +644,7 @@ export default class MapComponent extends Component {
|
||||
{{this.setupMap}}
|
||||
{{this.updateBookmarks}}
|
||||
{{this.updateSelectedPin}}
|
||||
{{this.syncPulse}}
|
||||
></div>
|
||||
</template>
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import Component from '@glimmer/component';
|
||||
import { fn } from '@ember/helper';
|
||||
import { on } from '@ember/modifier';
|
||||
import capitalize from '../helpers/capitalize';
|
||||
import { humanizeOsmTag } from '../utils/format-text';
|
||||
import Icon from '../components/icon';
|
||||
|
||||
import { tracked } from '@glimmer/tracking';
|
||||
@@ -76,14 +76,15 @@ export default class PlaceDetails extends Component {
|
||||
}
|
||||
|
||||
get type() {
|
||||
return (
|
||||
const rawType =
|
||||
this.tags.amenity ||
|
||||
this.tags.shop ||
|
||||
this.tags.tourism ||
|
||||
this.tags.leisure ||
|
||||
this.tags.historic ||
|
||||
'Point of Interest'
|
||||
);
|
||||
'Point of Interest';
|
||||
|
||||
return humanizeOsmTag(rawType);
|
||||
}
|
||||
|
||||
get address() {
|
||||
@@ -133,8 +134,7 @@ export default class PlaceDetails extends Component {
|
||||
if (!this.tags.cuisine) return null;
|
||||
return this.tags.cuisine
|
||||
.split(';')
|
||||
.map((c) => capitalize.compute([c]))
|
||||
.map((c) => c.replace('_', ' '))
|
||||
.map((c) => humanizeOsmTag(c))
|
||||
.join(', ');
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import { fn } from '@ember/helper';
|
||||
import or from 'ember-truth-helpers/helpers/or';
|
||||
import PlaceDetails from './place-details';
|
||||
import Icon from './icon';
|
||||
import humanizeOsmTag from '../helpers/humanize-osm-tag';
|
||||
|
||||
export default class PlacesSidebar extends Component {
|
||||
@service storage;
|
||||
@@ -23,13 +24,6 @@ export default class PlacesSidebar extends Component {
|
||||
if (this.args.onSelect) {
|
||||
this.args.onSelect(null);
|
||||
}
|
||||
|
||||
// Fallback logic: if no list available, close sidebar
|
||||
if (!this.args.places || this.args.places.length === 0) {
|
||||
if (this.args.onClose) {
|
||||
this.args.onClose();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@action
|
||||
@@ -122,7 +116,7 @@ export default class PlacesSidebar extends Component {
|
||||
try {
|
||||
const savedPlace = await this.storage.updatePlace(updatedPlace);
|
||||
console.log('Place updated:', savedPlace.title);
|
||||
|
||||
|
||||
// Notify parent to refresh map/lists
|
||||
if (this.args.onBookmarkChange) {
|
||||
this.args.onBookmarkChange();
|
||||
@@ -148,7 +142,7 @@ export default class PlacesSidebar extends Component {
|
||||
{{on "click" this.clearSelection}}
|
||||
><Icon @name="arrow-left" @size={{20}} @color="#333" /></button>
|
||||
{{else}}
|
||||
<h2>Nearby Places</h2>
|
||||
<h2><Icon @name="target" @size={{20}} @color="#ea4335" /> Nearby</h2>
|
||||
{{/if}}
|
||||
<button type="button" class="close-btn" {{on "click" @onClose}}><Icon
|
||||
@name="x"
|
||||
@@ -180,13 +174,13 @@ export default class PlacesSidebar extends Component {
|
||||
place.osmTags.name:en
|
||||
"Unnamed Place"
|
||||
}}</div>
|
||||
<div class="place-type">{{or
|
||||
<div class="place-type">{{humanizeOsmTag (or
|
||||
place.osmTags.amenity
|
||||
place.osmTags.shop
|
||||
place.osmTags.tourism
|
||||
place.osmTags.leisure
|
||||
place.osmTags.historic
|
||||
}}</div>
|
||||
)}}</div>
|
||||
</button>
|
||||
</li>
|
||||
{{/each}}
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
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);
|
||||
6
app/helpers/humanize-osm-tag.js
Normal file
6
app/helpers/humanize-osm-tag.js
Normal file
@@ -0,0 +1,6 @@
|
||||
import { helper } from '@ember/component/helper';
|
||||
import { humanizeOsmTag as format } from '../utils/format-text';
|
||||
|
||||
export default helper(function humanizeOsmTag([text]) {
|
||||
return format(text);
|
||||
});
|
||||
@@ -8,4 +8,5 @@ export default class Router extends EmberRouter {
|
||||
|
||||
Router.map(function () {
|
||||
this.route('place', { path: '/place/:place_id' });
|
||||
this.route('search');
|
||||
});
|
||||
|
||||
@@ -49,6 +49,8 @@ export default class PlaceRoute extends Route {
|
||||
if (model) {
|
||||
this.mapUi.selectPlace(model);
|
||||
}
|
||||
// Stop the pulse animation if it was running (e.g. redirected from search)
|
||||
this.mapUi.stopSearch();
|
||||
}
|
||||
|
||||
deactivate() {
|
||||
|
||||
96
app/routes/search.js
Normal file
96
app/routes/search.js
Normal file
@@ -0,0 +1,96 @@
|
||||
import Route from '@ember/routing/route';
|
||||
import { service } from '@ember/service';
|
||||
import { action } from '@ember/object';
|
||||
import { getDistance } from '../utils/geo';
|
||||
|
||||
export default class SearchRoute extends Route {
|
||||
@service osm;
|
||||
@service mapUi;
|
||||
@service storage;
|
||||
@service router;
|
||||
|
||||
queryParams = {
|
||||
lat: { refreshModel: true },
|
||||
lon: { refreshModel: true },
|
||||
q: { refreshModel: true },
|
||||
};
|
||||
|
||||
async model(params) {
|
||||
// If no coordinates, we can't search
|
||||
if (!params.lat || !params.lon) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const lat = parseFloat(params.lat);
|
||||
const lon = parseFloat(params.lon);
|
||||
const searchRadius = params.q ? 30 : 50;
|
||||
|
||||
// Fetch POIs
|
||||
let pois = await this.osm.getNearbyPois(lat, lon, searchRadius);
|
||||
|
||||
// Sort by distance from click
|
||||
pois = pois
|
||||
.map((p) => {
|
||||
return {
|
||||
...p,
|
||||
_distance: getDistance(lat, lon, p.lat, p.lon),
|
||||
};
|
||||
})
|
||||
.sort((a, b) => a._distance - b._distance);
|
||||
|
||||
// Check if any of these are already bookmarked
|
||||
// We resolve them to the bookmark version if they exist
|
||||
pois = pois.map((p) => {
|
||||
const saved = this.storage.findPlaceById(p.osmId);
|
||||
return saved || p;
|
||||
});
|
||||
|
||||
return pois;
|
||||
}
|
||||
|
||||
afterModel(model, transition) {
|
||||
const { q } = transition.to.queryParams;
|
||||
|
||||
// Heuristic Match Logic (ported from MapComponent)
|
||||
if (q && model.length > 0) {
|
||||
let matchedPlace = null;
|
||||
|
||||
// 1. Exact Name Match
|
||||
matchedPlace = model.find(
|
||||
(p) => p.osmTags && (p.osmTags.name === q || p.osmTags['name:en'] === q)
|
||||
);
|
||||
|
||||
// 2. High Proximity Match (<= 10m)
|
||||
// Note: MapComponent had logic for <=20m + type match.
|
||||
// We might want to pass the 'type' in queryParams if we want to be that precise.
|
||||
// For now, let's stick to name or very close proximity.
|
||||
if (!matchedPlace) {
|
||||
const topCandidate = model[0];
|
||||
if (topCandidate._distance <= 10) {
|
||||
matchedPlace = topCandidate;
|
||||
}
|
||||
}
|
||||
|
||||
if (matchedPlace) {
|
||||
// Direct transition!
|
||||
this.router.replaceWith('place', matchedPlace);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Stop the pulse animation since search is done (and we are staying here)
|
||||
this.mapUi.stopSearch();
|
||||
}
|
||||
|
||||
setupController(controller, model) {
|
||||
super.setupController(controller, model);
|
||||
// Ensure pulse is stopped if we reach here
|
||||
this.mapUi.stopSearch();
|
||||
}
|
||||
|
||||
@action
|
||||
error() {
|
||||
this.mapUi.stopSearch();
|
||||
return true; // Bubble error
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { tracked } from '@glimmer/tracking';
|
||||
|
||||
export default class MapUiService extends Service {
|
||||
@tracked selectedPlace = null;
|
||||
@tracked isSearching = false;
|
||||
|
||||
selectPlace(place) {
|
||||
this.selectedPlace = place;
|
||||
@@ -11,4 +12,12 @@ export default class MapUiService extends Service {
|
||||
clearSelection() {
|
||||
this.selectedPlace = null;
|
||||
}
|
||||
|
||||
startSearch() {
|
||||
this.isSearching = true;
|
||||
}
|
||||
|
||||
stopSearch() {
|
||||
this.isSearching = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,8 +4,18 @@ export default class OsmService extends Service {
|
||||
@service settings;
|
||||
|
||||
controller = null;
|
||||
cachedResults = null;
|
||||
lastQueryKey = null;
|
||||
|
||||
async getNearbyPois(lat, lon, radius = 50) {
|
||||
const queryKey = `${lat},${lon},${radius}`;
|
||||
|
||||
// Return cached results if the query is identical to the last one
|
||||
if (this.lastQueryKey === queryKey && this.cachedResults) {
|
||||
console.log('Returning cached Overpass results for:', queryKey);
|
||||
return this.cachedResults;
|
||||
}
|
||||
|
||||
// Cancel previous request if it exists
|
||||
if (this.controller) {
|
||||
this.controller.abort();
|
||||
@@ -33,7 +43,13 @@ out center;
|
||||
const data = await res.json();
|
||||
|
||||
// Normalize data
|
||||
return data.elements.map(this.normalizePoi);
|
||||
const results = data.elements.map(this.normalizePoi);
|
||||
|
||||
// Update cache
|
||||
this.lastQueryKey = queryKey;
|
||||
this.cachedResults = results;
|
||||
|
||||
return results;
|
||||
} catch (e) {
|
||||
if (e.name === 'AbortError') {
|
||||
console.log('Overpass request aborted');
|
||||
|
||||
@@ -4,12 +4,14 @@ html,
|
||||
body {
|
||||
height: 100%;
|
||||
overscroll-behavior: none; /* Prevent pull-to-refresh on mobile */
|
||||
-webkit-text-size-adjust: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: 'Noto Serif', serif;
|
||||
font-family: 'Noto Serif', sans-serif;
|
||||
font-size: 16px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
#root,
|
||||
@@ -95,7 +97,7 @@ body {
|
||||
.user-avatar-placeholder {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
background: #333;
|
||||
background: #2a3743;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -191,7 +193,6 @@ body {
|
||||
bottom: 0;
|
||||
width: 300px;
|
||||
background: white;
|
||||
color: #333;
|
||||
z-index: 3100; /* Higher than Header (3000) */
|
||||
box-shadow: 2px 0 5px rgb(0 0 0 / 10%);
|
||||
display: flex;
|
||||
@@ -225,10 +226,15 @@ body {
|
||||
.sidebar-header h2 {
|
||||
margin: 0;
|
||||
font-size: 1.2rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.sidebar-content {
|
||||
padding: 1rem;
|
||||
overflow-y: auto;
|
||||
flex: 1; /* Take up remaining vertical space */
|
||||
}
|
||||
|
||||
.edit-form {
|
||||
@@ -256,7 +262,7 @@ body {
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
font-family: inherit;
|
||||
font-size: 0.95rem;
|
||||
font-size: 1rem;
|
||||
box-sizing: border-box; /* Ensure padding doesn't overflow width */
|
||||
}
|
||||
|
||||
@@ -320,6 +326,11 @@ body {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.meta-info p {
|
||||
margin-top: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.meta-info p:first-child {
|
||||
margin-top: 1.2rem;
|
||||
padding-top: 1.2rem;
|
||||
@@ -359,29 +370,31 @@ body {
|
||||
.places-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
margin: -1rem -1rem 0 -1rem;
|
||||
}
|
||||
|
||||
.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;
|
||||
border: none;
|
||||
border-bottom: 1px solid #eee;
|
||||
background: #fff;
|
||||
color: #333;
|
||||
padding: 1rem;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.place-item:hover {
|
||||
background: #e9ecef;
|
||||
background: #eee;
|
||||
}
|
||||
|
||||
.place-name {
|
||||
font-size: 1rem;
|
||||
font-weight: bold;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
@@ -389,7 +402,6 @@ body {
|
||||
.place-type {
|
||||
color: #666;
|
||||
font-size: 0.85rem;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.back-btn {
|
||||
@@ -423,12 +435,11 @@ body {
|
||||
.place-details .place-type {
|
||||
color: #666;
|
||||
font-size: 0.9rem;
|
||||
text-transform: capitalize;
|
||||
margin: 0 0 1rem;
|
||||
}
|
||||
|
||||
.place-details .place-description {
|
||||
margin-bottom: 1.5rem;
|
||||
.place-details p.place-description {
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.place-details .actions {
|
||||
@@ -436,6 +447,7 @@ body {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 1rem;
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
.btn {
|
||||
@@ -495,11 +507,15 @@ body {
|
||||
border: 2px solid rgb(255 204 51 / 80%); /* Gold/Yellow to match markers */
|
||||
background: rgb(255 204 51 / 20%);
|
||||
position: absolute;
|
||||
transform: translate(-50%, -50%);
|
||||
/* Use translate3d for GPU acceleration on iOS */
|
||||
transform: translate3d(-50%, -50%, 0);
|
||||
pointer-events: none;
|
||||
animation: pulse 1.5s infinite ease-out;
|
||||
box-sizing: border-box; /* Ensure border is included in width/height */
|
||||
display: none; /* Hidden by default */
|
||||
will-change: transform, opacity;
|
||||
-webkit-backface-visibility: hidden;
|
||||
backface-visibility: hidden;
|
||||
}
|
||||
|
||||
.search-pulse.active {
|
||||
@@ -513,12 +529,12 @@ body {
|
||||
|
||||
@keyframes pulse {
|
||||
0% {
|
||||
transform: translate(-50%, -50%) scale(0.8);
|
||||
transform: translate3d(-50%, -50%, 0) scale(0.8);
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: translate(-50%, -50%) scale(1.4);
|
||||
transform: translate3d(-50%, -50%, 0) scale(1.4);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,26 +1,28 @@
|
||||
import Component from '@glimmer/component';
|
||||
import { pageTitle } from 'ember-page-title';
|
||||
import Map from '#components/map';
|
||||
import PlacesSidebar from '#components/places-sidebar';
|
||||
import AppHeader from '#components/app-header';
|
||||
import SettingsPane from '#components/settings-pane';
|
||||
import { service } from '@ember/service';
|
||||
import { tracked } from '@glimmer/tracking';
|
||||
import { action } from '@ember/object';
|
||||
import { eq } from 'ember-truth-helpers';
|
||||
import { and, or } from 'ember-truth-helpers';
|
||||
import { or } from 'ember-truth-helpers';
|
||||
import { on } from '@ember/modifier';
|
||||
|
||||
export default class ApplicationComponent extends Component {
|
||||
@service storage;
|
||||
@service mapUi;
|
||||
@service router;
|
||||
|
||||
@tracked nearbyPlaces = null;
|
||||
@tracked isSettingsOpen = false;
|
||||
// @tracked bookmarksVersion = 0; // Moved to storage service
|
||||
|
||||
get isSidebarOpen() {
|
||||
return !!this.nearbyPlaces || this.router.currentRouteName === 'place';
|
||||
// We consider the sidebar "open" if we are in search or place routes.
|
||||
// This helps the map know if it should shift the center or adjust view.
|
||||
return (
|
||||
this.router.currentRouteName === 'place' ||
|
||||
this.router.currentRouteName === 'search'
|
||||
);
|
||||
}
|
||||
|
||||
constructor() {
|
||||
@@ -30,32 +32,6 @@ export default class ApplicationComponent extends Component {
|
||||
this.storage;
|
||||
}
|
||||
|
||||
@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 (resolvedSelected) {
|
||||
// Pass the FULL object model to avoid re-fetching!
|
||||
// The Route's serialize() hook handles URL generation.
|
||||
this.router.transitionTo('place', resolvedSelected);
|
||||
this.nearbyPlaces = null; // Clear list when selecting specific
|
||||
} else if (resolvedPlaces && resolvedPlaces.length > 0) {
|
||||
// Show list case
|
||||
this.nearbyPlaces = resolvedPlaces;
|
||||
this.router.transitionTo('index');
|
||||
}
|
||||
}
|
||||
|
||||
@action
|
||||
toggleSettings() {
|
||||
this.isSettingsOpen = !this.isSettingsOpen;
|
||||
@@ -66,29 +42,20 @@ export default class ApplicationComponent extends Component {
|
||||
this.isSettingsOpen = false;
|
||||
}
|
||||
|
||||
@action
|
||||
selectFromList(place) {
|
||||
if (place) {
|
||||
// Optimize: Pass full object to avoid fetch
|
||||
this.router.transitionTo('place', place);
|
||||
}
|
||||
}
|
||||
|
||||
@action
|
||||
handleOutsideClick() {
|
||||
if (this.isSettingsOpen) {
|
||||
this.closeSettings();
|
||||
} else {
|
||||
this.closeSidebar();
|
||||
} else if (this.router.currentRouteName === 'search') {
|
||||
this.router.transitionTo('index');
|
||||
} else if (this.router.currentRouteName === 'place') {
|
||||
// If in place route, decide if we want to go back to search or index
|
||||
// For now, let's go to index or maybe back to search if search params exist?
|
||||
// Simplest behavior: clear selection
|
||||
this.router.transitionTo('index');
|
||||
}
|
||||
}
|
||||
|
||||
@action
|
||||
closeSidebar() {
|
||||
this.nearbyPlaces = null;
|
||||
this.router.transitionTo('index');
|
||||
}
|
||||
|
||||
@action
|
||||
refreshBookmarks() {
|
||||
this.storage.notifyChange();
|
||||
@@ -113,19 +80,10 @@ export default class ApplicationComponent extends Component {
|
||||
{{/if}}
|
||||
|
||||
<Map
|
||||
@onPlacesFound={{this.showPlaces}}
|
||||
@isSidebarOpen={{or this.isSidebarOpen this.isSettingsOpen}}
|
||||
@onOutsideClick={{this.handleOutsideClick}}
|
||||
/>
|
||||
|
||||
{{#if (and (eq this.router.currentRouteName "index") this.nearbyPlaces)}}
|
||||
<PlacesSidebar
|
||||
@places={{this.nearbyPlaces}}
|
||||
@onSelect={{this.selectFromList}}
|
||||
@onClose={{this.closeSidebar}}
|
||||
/>
|
||||
{{/if}}
|
||||
|
||||
{{#if this.isSettingsOpen}}
|
||||
<SettingsPane @onClose={{this.closeSettings}} />
|
||||
{{/if}}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { tracked } from '@glimmer/tracking';
|
||||
export default class PlaceTemplate extends Component {
|
||||
@service router;
|
||||
@service storage;
|
||||
@service mapUi;
|
||||
|
||||
@tracked localPlace = null;
|
||||
|
||||
@@ -72,8 +73,26 @@ export default class PlaceTemplate extends Component {
|
||||
this.storage.notifyChange();
|
||||
}
|
||||
|
||||
@action
|
||||
navigateBack(place) {
|
||||
// The sidebar calls this with null when "Back" is clicked.
|
||||
if (place === null) {
|
||||
// If we have history, go back (preserves search state)
|
||||
if (window.history.length > 1) {
|
||||
window.history.back();
|
||||
} else {
|
||||
// Fallback if opened directly
|
||||
this.router.transitionTo('index');
|
||||
}
|
||||
} else {
|
||||
// If a place is selected (unlikely in this view, but possible if we add related links)
|
||||
this.router.transitionTo('place', place);
|
||||
}
|
||||
}
|
||||
|
||||
@action
|
||||
close() {
|
||||
// Clear search results so we don't fall back to the list
|
||||
this.router.transitionTo('index');
|
||||
}
|
||||
|
||||
@@ -81,6 +100,7 @@ export default class PlaceTemplate extends Component {
|
||||
<PlacesSidebar
|
||||
@selectedPlace={{this.place}}
|
||||
@onClose={{this.close}}
|
||||
@onSelect={{this.navigateBack}}
|
||||
@onBookmarkChange={{this.refreshMap}}
|
||||
@onUpdate={{this.handleUpdate}}
|
||||
/>
|
||||
|
||||
28
app/templates/search.gjs
Normal file
28
app/templates/search.gjs
Normal file
@@ -0,0 +1,28 @@
|
||||
import Component from '@glimmer/component';
|
||||
import PlacesSidebar from '#components/places-sidebar';
|
||||
import { service } from '@ember/service';
|
||||
import { action } from '@ember/object';
|
||||
|
||||
export default class SearchTemplate extends Component {
|
||||
@service router;
|
||||
|
||||
@action
|
||||
selectPlace(place) {
|
||||
if (place) {
|
||||
this.router.transitionTo('place', place);
|
||||
}
|
||||
}
|
||||
|
||||
@action
|
||||
close() {
|
||||
this.router.transitionTo('index');
|
||||
}
|
||||
|
||||
<template>
|
||||
<PlacesSidebar
|
||||
@places={{@model}}
|
||||
@onSelect={{this.selectPlace}}
|
||||
@onClose={{this.close}}
|
||||
/>
|
||||
</template>
|
||||
}
|
||||
9
app/utils/format-text.js
Normal file
9
app/utils/format-text.js
Normal file
@@ -0,0 +1,9 @@
|
||||
export function humanizeOsmTag(text) {
|
||||
if (typeof text !== 'string' || !text) return '';
|
||||
// Replace underscores and dashes with spaces
|
||||
const spaced = text.replace(/[_-]/g, ' ');
|
||||
// Capitalize first letter of each word (Title Case)
|
||||
return spaced.replace(/\w\S*/g, (w) =>
|
||||
w.replace(/^\w/, (c) => c.toUpperCase())
|
||||
);
|
||||
}
|
||||
@@ -9,7 +9,7 @@
|
||||
<!-- App identity -->
|
||||
<meta name="application-name" content="Marco">
|
||||
<meta name="apple-mobile-web-app-title" content="Marco">
|
||||
<meta name="theme-color" content="#333333">
|
||||
<meta name="theme-color" content="#2a3743">
|
||||
|
||||
<!-- PWA Manifest -->
|
||||
<link rel="manifest" href="/web-app-manifest.json">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "marco",
|
||||
"version": "1.8.5",
|
||||
"version": "1.9.0",
|
||||
"private": true,
|
||||
"description": "Unhosted maps app",
|
||||
"repository": {
|
||||
@@ -50,7 +50,7 @@
|
||||
"@embroider/vite": "^1.5.0",
|
||||
"@eslint/js": "^9.39.2",
|
||||
"@glimmer/component": "^2.0.0",
|
||||
"@remotestorage/module-places": "link:vendor/remotestorage-module-places",
|
||||
"@remotestorage/module-places": "1.x",
|
||||
"@rollup/plugin-babel": "^6.1.0",
|
||||
"@warp-drive/core": "~5.8.0",
|
||||
"@warp-drive/ember": "~5.8.0",
|
||||
|
||||
18
pnpm-lock.yaml
generated
18
pnpm-lock.yaml
generated
@@ -52,8 +52,8 @@ importers:
|
||||
specifier: ^2.0.0
|
||||
version: 2.0.0
|
||||
'@remotestorage/module-places':
|
||||
specifier: link:vendor/remotestorage-module-places
|
||||
version: link:vendor/remotestorage-module-places
|
||||
specifier: 1.x
|
||||
version: 1.0.0
|
||||
'@rollup/plugin-babel':
|
||||
specifier: ^6.1.0
|
||||
version: 6.1.0(@babel/core@7.28.6)(rollup@4.55.1)
|
||||
@@ -1377,6 +1377,9 @@ packages:
|
||||
resolution: {integrity: sha512-4rdu8GPY9TeQwsYp5D2My74dC3dSVS3tghAvisG80ybK4lqa0gvlrglaSTBxogJbxqHRw/NjI/liEtb3+SD+Bw==}
|
||||
engines: {node: '>=18.12'}
|
||||
|
||||
'@remotestorage/module-places@1.0.0':
|
||||
resolution: {integrity: sha512-vaqJeTw658gjPyLz70Mq2AbGfDZ66O2mpDFME+gtaGFYl2+UvrvRLCrXWHYuyTE21f3TJdegeXM6C5nZMxLv9A==}
|
||||
|
||||
'@rollup/plugin-babel@6.1.0':
|
||||
resolution: {integrity: sha512-dFZNuFD2YRcoomP4oYf+DvQNSUA9ih+A3vUqopQx5EdtPGo3WBnQcI/S8pwpz91UsGfL0HsMSOlaMld8HrbubA==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
@@ -5180,6 +5183,10 @@ packages:
|
||||
engines: {node: '>=0.8.0'}
|
||||
hasBin: true
|
||||
|
||||
ulid@3.0.2:
|
||||
resolution: {integrity: sha512-yu26mwteFYzBAot7KVMqFGCVpsF6g8wXfJzQUHvu1no3+rRRSFcSV2nKeYvNPLD2J4b08jYBDhHUjeH0ygIl9w==}
|
||||
hasBin: true
|
||||
|
||||
underscore.string@3.3.6:
|
||||
resolution: {integrity: sha512-VoC83HWXmCrF6rgkyxS9GHv8W9Q5nhMKho+OadDJGzL2oDYbYEppBaCMH6pFlwLeqj2QS+hhkw2kpXkSdD1JxQ==}
|
||||
|
||||
@@ -6967,6 +6974,11 @@ snapshots:
|
||||
'@pnpm/error': 1000.0.5
|
||||
find-up: 5.0.0
|
||||
|
||||
'@remotestorage/module-places@1.0.0':
|
||||
dependencies:
|
||||
latlon-geohash: 2.0.0
|
||||
ulid: 3.0.2
|
||||
|
||||
'@rollup/plugin-babel@6.1.0(@babel/core@7.28.6)(rollup@4.55.1)':
|
||||
dependencies:
|
||||
'@babel/core': 7.28.6
|
||||
@@ -11449,6 +11461,8 @@ snapshots:
|
||||
uglify-js@3.19.3:
|
||||
optional: true
|
||||
|
||||
ulid@3.0.2: {}
|
||||
|
||||
underscore.string@3.3.6:
|
||||
dependencies:
|
||||
sprintf-js: 1.1.3
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"scope": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#f8f9fa",
|
||||
"theme_color": "#333333",
|
||||
"theme_color": "#2a3743",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/icons/icon-192.png",
|
||||
|
||||
1
release/assets/main-BZSIy5va.css
Normal file
1
release/assets/main-BZSIy5va.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
2
release/assets/main-DwYp7tls.js
Normal file
2
release/assets/main-DwYp7tls.js
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -9,7 +9,7 @@
|
||||
<!-- App identity -->
|
||||
<meta name="application-name" content="Marco">
|
||||
<meta name="apple-mobile-web-app-title" content="Marco">
|
||||
<meta name="theme-color" content="#333333">
|
||||
<meta name="theme-color" content="#2a3743">
|
||||
|
||||
<!-- PWA Manifest -->
|
||||
<link rel="manifest" href="/web-app-manifest.json">
|
||||
@@ -26,8 +26,8 @@
|
||||
<meta name="msapplication-TileColor" content="#F6E9A6">
|
||||
<meta name="msapplication-TileImage" content="/icons/icon-144.png">
|
||||
|
||||
<script type="module" crossorigin src="/assets/main-DsIm8MXw.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/main-Csx4lQiv.css">
|
||||
<script type="module" crossorigin src="/assets/main-DwYp7tls.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/main-BZSIy5va.css">
|
||||
</head>
|
||||
<body>
|
||||
</body>
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"scope": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#f8f9fa",
|
||||
"theme_color": "#333333",
|
||||
"theme_color": "#2a3743",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/icons/icon-192.png",
|
||||
|
||||
1
vendor/remotestorage-module-places
vendored
1
vendor/remotestorage-module-places
vendored
@@ -1 +0,0 @@
|
||||
/home/basti/src/remotestorage/modules/remotestorage-module-places
|
||||
Reference in New Issue
Block a user