Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
585837cae7
|
|||
|
42c5282844
|
|||
|
8a0603c65e
|
|||
|
8e3187f38d
|
|||
|
a73e5cda6a
|
|||
|
0212fa359b
|
|||
|
8c58a76030
|
|||
|
a10f87290a
|
|||
|
e7b3b72e2f
|
|||
|
399ad1822d
|
|||
|
104a742543
|
|||
|
a8dc4c81e4
|
|||
|
156280950f
|
@@ -1,6 +1,6 @@
|
||||
# Project Status: Marco
|
||||
|
||||
**Last Updated:** Mon Jan 26 2026
|
||||
**Last Updated:** Tue Jan 27 2026
|
||||
|
||||
## Project Context
|
||||
|
||||
@@ -15,7 +15,6 @@ We are building **Marco**, a decentralized maps application using **Ember.js** (
|
||||
- Implemented a hybrid click handler:
|
||||
- Detects clicks on visual vector tiles.
|
||||
- Falls back to fetching authoritative data from an **Overpass API** service.
|
||||
- Uses a **heuristic** (distance + type matching) to link visual clicks to API results (handling data desynchronization).
|
||||
- **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.
|
||||
@@ -58,6 +57,7 @@ We are building **Marco**, a decentralized maps application using **Ember.js** (
|
||||
- `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`.
|
||||
- **UI Components:**
|
||||
- `places-sidebar.gjs`: Displays a list of nearby POIs.
|
||||
@@ -73,26 +73,34 @@ 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.
|
||||
- **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".
|
||||
- **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
|
||||
### 4. Routing & Architecture (Refactored)
|
||||
|
||||
- **Explicit URLs:** Implemented routing support for specific OSM entities via `/place/osm:node:<id>` and `/place/osm:way:<id>`, distinguishing them from local bookmarks (ULIDs).
|
||||
- **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.
|
||||
- **Performance:** Optimized navigation to prevent redundant network requests. Clicking a map pin passes the existing data object to the route, skipping the `model` hook (no re-fetch) while maintaining correct deep-linkable URLs via a custom `serialize` hook in `PlaceRoute`.
|
||||
|
||||
## Current State
|
||||
|
||||
- **Repo:** The app runs via `pnpm start`.
|
||||
- **Workflow:**
|
||||
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 checked against bookmarks; if found, it uses the Bookmark object. Otherwise, it uses the OSM object.
|
||||
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. User clicks "Save Bookmark" -> Stores JSON in RemoteStorage.
|
||||
6. RemoteStorage change event -> Debounced reload updates the map reactive-ly.
|
||||
@@ -101,17 +109,15 @@ We are building **Marco**, a decentralized maps application using **Ember.js** (
|
||||
|
||||
## Files Currently in Focus
|
||||
|
||||
- `app/styles/app.css`: Mobile CSS fixes (font sizes, control positioning).
|
||||
- `package.json`: New scripts and dependencies.
|
||||
- `README.md`: Updated documentation.
|
||||
- `app/services/osm.js`: Caching logic.
|
||||
- `app/routes/search.js`: Search heuristics.
|
||||
- `app/components/place-details.gjs`: Formatting logic.
|
||||
|
||||
## Next Steps & Pending Tasks
|
||||
|
||||
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.
|
||||
1. **Linting & Code Quality:** Fix remaining CSS errors, remove inline styles in `map.gjs`, 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
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ import mapPin from 'feather-icons/dist/icons/map-pin.svg?raw';
|
||||
import menu from 'feather-icons/dist/icons/menu.svg?raw';
|
||||
import navigation from 'feather-icons/dist/icons/navigation.svg?raw';
|
||||
import phone from 'feather-icons/dist/icons/phone.svg?raw';
|
||||
import plus from 'feather-icons/dist/icons/plus.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';
|
||||
@@ -37,6 +38,7 @@ const ICONS = {
|
||||
menu,
|
||||
navigation,
|
||||
phone,
|
||||
plus,
|
||||
server,
|
||||
settings,
|
||||
target,
|
||||
|
||||
@@ -15,12 +15,12 @@ import Point from 'ol/geom/Point.js';
|
||||
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';
|
||||
|
||||
export default class MapComponent extends Component {
|
||||
@service osm;
|
||||
@service storage;
|
||||
@service mapUi;
|
||||
@service router;
|
||||
|
||||
mapInstance;
|
||||
bookmarkSource;
|
||||
@@ -28,6 +28,8 @@ export default class MapComponent extends Component {
|
||||
searchOverlayElement;
|
||||
selectedPinOverlay;
|
||||
selectedPinElement;
|
||||
crosshairElement;
|
||||
crosshairOverlay;
|
||||
|
||||
setupMap = modifier((element) => {
|
||||
if (this.mapInstance) return;
|
||||
@@ -112,18 +114,13 @@ 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:
|
||||
// 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');
|
||||
@@ -134,11 +131,25 @@ export default class MapComponent extends Component {
|
||||
|
||||
this.selectedPinOverlay = new Overlay({
|
||||
element: this.selectedPinElement,
|
||||
positioning: 'bottom-center', // Important: Pin tip is at the bottom
|
||||
positioning: 'bottom-center', // Pin tip is at the bottom
|
||||
stopEvent: false, // Let clicks pass through
|
||||
});
|
||||
this.mapInstance.addOverlay(this.selectedPinOverlay);
|
||||
|
||||
// Crosshair Overlay (for Creating New Place)
|
||||
this.crosshairElement = document.createElement('div');
|
||||
this.crosshairElement.className = 'map-crosshair';
|
||||
this.crosshairElement.innerHTML = `
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="12" y1="5" x2="12" y2="19"></line>
|
||||
<line x1="5" y1="12" x2="19" y2="12"></line>
|
||||
</svg>
|
||||
`;
|
||||
element.appendChild(this.crosshairElement);
|
||||
|
||||
|
||||
|
||||
|
||||
// Geolocation Pulse Overlay
|
||||
this.locationOverlayElement = document.createElement('div');
|
||||
this.locationOverlayElement.className = 'search-pulse blue';
|
||||
@@ -281,7 +292,7 @@ export default class MapComponent extends Component {
|
||||
};
|
||||
|
||||
if (targetResolution) {
|
||||
const maxResolution = view.getResolutionForZoom(17); // Use 17 as safe max zoom for accuracy < 20m
|
||||
const maxResolution = view.getResolutionForZoom(17);
|
||||
viewOptions.resolution = Math.max(targetResolution, maxResolution);
|
||||
} else {
|
||||
viewOptions.zoom = 16;
|
||||
@@ -337,16 +348,9 @@ export default class MapComponent extends Component {
|
||||
this.mapInstance.getTarget().style.cursor = hit ? 'pointer' : '';
|
||||
});
|
||||
|
||||
// Load initial bookmarks
|
||||
this.storage.rs.on('ready', () => {
|
||||
// Initial load based on current view
|
||||
this.handleMapMove();
|
||||
});
|
||||
|
||||
// Listen for remote storage changes
|
||||
// this.storage.rs.on('connected', () => {
|
||||
// this.loadBookmarks();
|
||||
// });
|
||||
});
|
||||
|
||||
// Track the selected place from the UI Service (Router -> Map)
|
||||
@@ -415,12 +419,9 @@ export default class MapComponent extends Component {
|
||||
const offsetPixels = height * 0.25; // Distance from desired pin pos to map center
|
||||
const offsetMapUnits = offsetPixels * resolution;
|
||||
|
||||
// Shift center SOUTH (decrease Y)
|
||||
// Shift center SOUTH (decrease Y).
|
||||
// Note: In Web Mercator (EPSG:3857), Y increases North.
|
||||
// So to look "lower", we decrease Y? No wait.
|
||||
// If we move the camera South (decrease Y), the features move North (Up) on screen.
|
||||
// We want the Pin (fixed lat/lon) to be Higher up on screen.
|
||||
// So we must move the Camera South (Lower Y).
|
||||
// To move the camera South (Lower Y), we subtract.
|
||||
targetCenter = [coords[0], coords[1] - offsetMapUnits];
|
||||
}
|
||||
|
||||
@@ -468,7 +469,6 @@ export default class MapComponent extends Component {
|
||||
}
|
||||
}
|
||||
|
||||
// Re-fetch bookmarks when the version changes (triggered by parent action or service)
|
||||
updateBookmarks = modifier(() => {
|
||||
// Depend on the tracked storage.placesInView to automatically update when they change
|
||||
const places = this.storage.placesInView;
|
||||
@@ -480,15 +480,10 @@ export default class MapComponent extends Component {
|
||||
if (!this.bookmarkSource) return;
|
||||
|
||||
if (!places || places.length === 0) {
|
||||
// Fallback or explicit check if we have tracked property usage?
|
||||
// The service updates 'placesInView'. We should probably use that if we want reactiveness.
|
||||
places = this.storage.placesInView;
|
||||
}
|
||||
|
||||
// Previously: const places = await this.storage.places.getPlaces();
|
||||
// We no longer want to fetch everything blindly.
|
||||
// We rely on 'placesInView' being updated by handleMapMove calling storage.loadPlacesInBounds.
|
||||
|
||||
this.bookmarkSource.clear();
|
||||
|
||||
if (places && Array.isArray(places)) {
|
||||
@@ -510,9 +505,120 @@ 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');
|
||||
}
|
||||
});
|
||||
|
||||
// Sync the creation mode (Crosshair)
|
||||
syncCreationMode = modifier(() => {
|
||||
if (!this.crosshairElement || !this.mapInstance) return;
|
||||
|
||||
if (this.mapUi.isCreating) {
|
||||
this.crosshairElement.classList.add('visible');
|
||||
|
||||
// If we have initial coordinates from the route (e.g. reload or link),
|
||||
// we need to pan the map so those coordinates are UNDER the crosshair.
|
||||
const coords = this.mapUi.creationCoordinates;
|
||||
if (coords && coords.lat && coords.lon) {
|
||||
// We only animate if the map center isn't already "roughly" correct.
|
||||
const targetCoords = fromLonLat([coords.lon, coords.lat]);
|
||||
this.animateToCrosshair(targetCoords);
|
||||
}
|
||||
} else {
|
||||
this.crosshairElement.classList.remove('visible');
|
||||
}
|
||||
});
|
||||
|
||||
animateToCrosshair(targetCoords) {
|
||||
if (!this.mapInstance || !this.crosshairElement) return;
|
||||
|
||||
// 1. Get current visual position of the crosshair
|
||||
const mapRect = this.mapInstance.getTargetElement().getBoundingClientRect();
|
||||
const crosshairRect = this.crosshairElement.getBoundingClientRect();
|
||||
|
||||
const crosshairPixelX =
|
||||
crosshairRect.left + crosshairRect.width / 2 - mapRect.left;
|
||||
const crosshairPixelY =
|
||||
crosshairRect.top + crosshairRect.height / 2 - mapRect.top;
|
||||
|
||||
// 2. Get the center pixel of the map viewport
|
||||
const size = this.mapInstance.getSize();
|
||||
const mapCenterX = size[0] / 2;
|
||||
const mapCenterY = size[1] / 2;
|
||||
|
||||
// 3. Calculate the offset (how far the crosshair is from the geometric center)
|
||||
const offsetX = crosshairPixelX - mapCenterX;
|
||||
const offsetY = crosshairPixelY - mapCenterY;
|
||||
|
||||
// 4. Calculate the new map center
|
||||
// We want 'targetCoords' to be at [crosshairPixelX, crosshairPixelY].
|
||||
// If we center the map on 'targetCoords', it will be at [mapCenterX, mapCenterY].
|
||||
// So we need to shift the map center by the OPPOSITE of the offset.
|
||||
const view = this.mapInstance.getView();
|
||||
const resolution = view.getResolution();
|
||||
|
||||
const offsetMapUnitsX = offsetX * resolution;
|
||||
const offsetMapUnitsY = -offsetY * resolution; // Y is inverted in pixel vs map coords
|
||||
|
||||
const targetX = targetCoords[0];
|
||||
const targetY = targetCoords[1];
|
||||
|
||||
const newCenterX = targetX - offsetMapUnitsX;
|
||||
const newCenterY = targetY - offsetMapUnitsY;
|
||||
|
||||
// Only animate if the difference is significant (avoid micro-jitters/loops)
|
||||
const currentCenter = view.getCenter();
|
||||
const dist = Math.sqrt(
|
||||
Math.pow(currentCenter[0] - newCenterX, 2) +
|
||||
Math.pow(currentCenter[1] - newCenterY, 2)
|
||||
);
|
||||
|
||||
// 1 meter is approx 1 unit in Mercator near equator, varies by latitude.
|
||||
// Resolution at zoom 18 is approx 0.6m/pixel.
|
||||
// Let's use a small pixel threshold.
|
||||
if (dist > resolution * 5) {
|
||||
view.animate({
|
||||
center: [newCenterX, newCenterY],
|
||||
duration: 800,
|
||||
easing: (t) => t * (2 - t), // Ease-out
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
handleMapMove = async () => {
|
||||
if (!this.mapInstance) return;
|
||||
|
||||
// If in creation mode, update the coordinates in the service AND the URL
|
||||
if (this.mapUi.isCreating) {
|
||||
// Calculate coordinates under the crosshair element
|
||||
// We need the pixel position of the crosshair relative to the map viewport
|
||||
// The crosshair is positioned via CSS, so we can use getBoundingClientRect
|
||||
const mapRect = this.mapInstance.getTargetElement().getBoundingClientRect();
|
||||
const crosshairRect = this.crosshairElement.getBoundingClientRect();
|
||||
|
||||
const centerX = crosshairRect.left + crosshairRect.width / 2 - mapRect.left;
|
||||
const centerY = crosshairRect.top + crosshairRect.height / 2 - mapRect.top;
|
||||
|
||||
const coordinate = this.mapInstance.getCoordinateFromPixel([centerX, centerY]);
|
||||
const center = toLonLat(coordinate);
|
||||
|
||||
const lat = parseFloat(center[1].toFixed(6));
|
||||
const lon = parseFloat(center[0].toFixed(6));
|
||||
|
||||
this.mapUi.updateCreationCoordinates(lat, lon);
|
||||
|
||||
// Update URL without triggering a full refresh
|
||||
// We use replaceWith to avoid cluttering history
|
||||
this.router.replaceWith('place.new', { queryParams: { lat, lon } });
|
||||
}
|
||||
|
||||
const size = this.mapInstance.getSize();
|
||||
const extent = this.mapInstance.getView().calculateExtent(size);
|
||||
const [minLon, minLat] = toLonLat([extent[0], extent[1]]);
|
||||
@@ -546,7 +652,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 +666,6 @@ export default class MapComponent extends Component {
|
||||
const props = features[0].getProperties();
|
||||
if (props.name) {
|
||||
selectedFeatureName = props.name;
|
||||
selectedFeatureType = props.class || props.subclass;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -569,13 +673,11 @@ export default class MapComponent extends Component {
|
||||
if (this.args.isSidebarOpen) {
|
||||
// If it's a bookmark, we allow "switching" to it even if sidebar is open
|
||||
if (clickedBookmark) {
|
||||
console.log(
|
||||
console.debug(
|
||||
'Clicked bookmark while sidebar open (switching):',
|
||||
clickedBookmark
|
||||
);
|
||||
if (this.args.onPlacesFound) {
|
||||
this.args.onPlacesFound([], clickedBookmark);
|
||||
}
|
||||
this.router.transitionTo('place', clickedBookmark);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -588,10 +690,8 @@ 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);
|
||||
}
|
||||
console.debug('Clicked bookmark:', clickedBookmark);
|
||||
this.router.transitionTo('place', clickedBookmark);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -615,84 +715,31 @@ 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>
|
||||
<div
|
||||
class="map-container"
|
||||
class="map-container {{if @isSidebarOpen 'sidebar-open'}}"
|
||||
{{this.setupMap}}
|
||||
{{this.updateBookmarks}}
|
||||
{{this.updateSelectedPin}}
|
||||
{{this.syncPulse}}
|
||||
{{this.syncCreationMode}}
|
||||
></div>
|
||||
</template>
|
||||
}
|
||||
|
||||
@@ -1,21 +1,15 @@
|
||||
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 PlaceEditForm from './place-edit-form';
|
||||
|
||||
import { tracked } from '@glimmer/tracking';
|
||||
import { action } from '@ember/object';
|
||||
|
||||
export default class PlaceDetails extends Component {
|
||||
@tracked isEditing = false;
|
||||
@tracked editTitle = '';
|
||||
@tracked editDescription = '';
|
||||
|
||||
constructor() {
|
||||
super(...arguments);
|
||||
this.resetEditFields();
|
||||
}
|
||||
|
||||
get place() {
|
||||
return this.args.place || {};
|
||||
@@ -34,16 +28,9 @@ export default class PlaceDetails extends Component {
|
||||
);
|
||||
}
|
||||
|
||||
@action
|
||||
resetEditFields() {
|
||||
this.editTitle = this.name;
|
||||
this.editDescription = this.place.description || '';
|
||||
}
|
||||
|
||||
@action
|
||||
startEditing() {
|
||||
if (!this.place.createdAt) return; // Only allow editing saved places
|
||||
this.resetEditFields();
|
||||
this.isEditing = true;
|
||||
}
|
||||
|
||||
@@ -53,37 +40,26 @@ export default class PlaceDetails extends Component {
|
||||
}
|
||||
|
||||
@action
|
||||
async saveChanges(event) {
|
||||
event.preventDefault();
|
||||
async saveChanges(changes) {
|
||||
if (this.args.onSave) {
|
||||
await this.args.onSave({
|
||||
...this.place,
|
||||
title: this.editTitle,
|
||||
description: this.editDescription,
|
||||
...changes,
|
||||
});
|
||||
}
|
||||
this.isEditing = false;
|
||||
}
|
||||
|
||||
@action
|
||||
updateTitle(e) {
|
||||
this.editTitle = e.target.value;
|
||||
}
|
||||
|
||||
@action
|
||||
updateDescription(e) {
|
||||
this.editDescription = e.target.value;
|
||||
}
|
||||
|
||||
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 +109,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(', ');
|
||||
}
|
||||
|
||||
@@ -165,38 +140,19 @@ export default class PlaceDetails extends Component {
|
||||
}
|
||||
|
||||
get gmapsUrl() {
|
||||
const id = this.place.gmapsId || this.place.osmId;
|
||||
if (!id) return null;
|
||||
return `https://www.google.com/maps/search/?api=1&query=${this.name}&query=${this.place.lat},${this.place.lon}`;
|
||||
}
|
||||
|
||||
<template>
|
||||
<div class="place-details">
|
||||
{{#if this.isEditing}}
|
||||
<form class="edit-form" {{on "submit" this.saveChanges}}>
|
||||
<div class="form-group">
|
||||
<label for="edit-title">Title</label>
|
||||
<input
|
||||
id="edit-title"
|
||||
type="text"
|
||||
value={{this.editTitle}}
|
||||
{{on "input" this.updateTitle}}
|
||||
class="form-control"
|
||||
/>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="edit-desc">Description</label>
|
||||
<textarea
|
||||
id="edit-desc"
|
||||
value={{this.editDescription}}
|
||||
{{on "input" this.updateDescription}}
|
||||
class="form-control"
|
||||
rows="3"
|
||||
></textarea>
|
||||
</div>
|
||||
<div class="edit-actions">
|
||||
<button type="submit" class="btn btn-blue">Save</button>
|
||||
<button type="button" class="btn btn-outline" {{on "click" this.cancelEditing}}>Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
<PlaceEditForm
|
||||
@place={{this.place}}
|
||||
@onSave={{this.saveChanges}}
|
||||
@onCancel={{this.cancelEditing}}
|
||||
/>
|
||||
{{else}}
|
||||
<h3>{{this.name}}</h3>
|
||||
<p class="place-type">
|
||||
@@ -305,7 +261,7 @@ export default class PlaceDetails extends Component {
|
||||
|
||||
{{#if this.osmUrl}}
|
||||
<p class="content-with-icon">
|
||||
<Icon @name="map" @title="OSM ID" />
|
||||
<Icon @name="map" />
|
||||
<span>
|
||||
<a href={{this.osmUrl}} target="_blank" rel="noopener noreferrer">
|
||||
OpenStreetMap
|
||||
@@ -314,14 +270,16 @@ export default class PlaceDetails extends Component {
|
||||
</p>
|
||||
{{/if}}
|
||||
|
||||
<p class="content-with-icon">
|
||||
<Icon @name="map" @title="OSM ID" />
|
||||
<span>
|
||||
<a href={{this.gmapsUrl}} target="_blank" rel="noopener noreferrer">
|
||||
Google Maps
|
||||
</a>
|
||||
</span>
|
||||
</p>
|
||||
{{#if this.gmapsUrl}}
|
||||
<p class="content-with-icon">
|
||||
<Icon @name="map" />
|
||||
<span>
|
||||
<a href={{this.gmapsUrl}} target="_blank" rel="noopener noreferrer">
|
||||
Google Maps
|
||||
</a>
|
||||
</span>
|
||||
</p>
|
||||
{{/if}}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
72
app/components/place-edit-form.gjs
Normal file
72
app/components/place-edit-form.gjs
Normal file
@@ -0,0 +1,72 @@
|
||||
import Component from '@glimmer/component';
|
||||
import { on } from '@ember/modifier';
|
||||
import { tracked } from '@glimmer/tracking';
|
||||
import { action } from '@ember/object';
|
||||
|
||||
export default class PlaceEditForm extends Component {
|
||||
@tracked title = '';
|
||||
@tracked description = '';
|
||||
|
||||
constructor() {
|
||||
super(...arguments);
|
||||
this.title = this.args.place?.title || '';
|
||||
this.description = this.args.place?.description || '';
|
||||
}
|
||||
|
||||
@action
|
||||
handleSubmit(event) {
|
||||
event.preventDefault();
|
||||
if (this.args.onSave) {
|
||||
this.args.onSave({
|
||||
title: this.title,
|
||||
description: this.description,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@action
|
||||
updateTitle(e) {
|
||||
this.title = e.target.value;
|
||||
}
|
||||
|
||||
@action
|
||||
updateDescription(e) {
|
||||
this.description = e.target.value;
|
||||
}
|
||||
|
||||
<template>
|
||||
<form class="edit-form" {{on "submit" this.handleSubmit}}>
|
||||
<div class="form-group">
|
||||
<label for="edit-title">Title</label>
|
||||
<input
|
||||
id="edit-title"
|
||||
type="text"
|
||||
value={{this.title}}
|
||||
{{on "input" this.updateTitle}}
|
||||
class="form-control"
|
||||
placeholder="Name of the place"
|
||||
autofocus
|
||||
/>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="edit-desc">Description</label>
|
||||
<textarea
|
||||
id="edit-desc"
|
||||
value={{this.description}}
|
||||
{{on "input" this.updateDescription}}
|
||||
class="form-control"
|
||||
rows="3"
|
||||
placeholder="Add some details..."
|
||||
></textarea>
|
||||
</div>
|
||||
<div class="edit-actions">
|
||||
<button type="submit" class="btn btn-blue">Save</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-outline"
|
||||
{{on "click" @onCancel}}
|
||||
>Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
}
|
||||
@@ -6,9 +6,26 @@ 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;
|
||||
@service router;
|
||||
@service mapUi;
|
||||
|
||||
@action
|
||||
createNewPlace() {
|
||||
const qp = this.router.currentRoute.queryParams;
|
||||
const lat = qp.lat;
|
||||
const lon = qp.lon;
|
||||
|
||||
if (lat && lon) {
|
||||
this.router.transitionTo('place.new', { queryParams: { lat, lon } });
|
||||
} else {
|
||||
// Fallback (shouldn't happen in search context)
|
||||
this.router.transitionTo('place.new', { queryParams: { lat: 0, lon: 0 } });
|
||||
}
|
||||
}
|
||||
|
||||
@action
|
||||
selectPlace(place) {
|
||||
@@ -23,13 +40,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
|
||||
@@ -40,19 +50,14 @@ export default class PlacesSidebar extends Component {
|
||||
if (confirm(`Delete "${place.title}"?`)) {
|
||||
try {
|
||||
await this.storage.removePlace(place);
|
||||
console.log('Place deleted:', place.title);
|
||||
console.debug('Place deleted:', place.title);
|
||||
|
||||
// Notify parent to refresh map bookmarks
|
||||
if (this.args.onBookmarkChange) {
|
||||
this.args.onBookmarkChange();
|
||||
}
|
||||
|
||||
// Update selection to the new saved place object
|
||||
// This updates the local UI state immediately without a route refresh
|
||||
if (this.args.onUpdate) {
|
||||
// When deleting, we revert to a "fresh" object or just close.
|
||||
// Since we close the sidebar below, we might not strictly need to update local state,
|
||||
// but it's good practice.
|
||||
// Reconstruct the "original" place without ID/Geohash/CreatedAt
|
||||
const freshPlace = {
|
||||
...place,
|
||||
@@ -65,7 +70,6 @@ export default class PlacesSidebar extends Component {
|
||||
|
||||
// Also fire onSelect if it exists (for list view)
|
||||
if (this.args.onSelect) {
|
||||
// Similar logic for select if needed, but we usually close.
|
||||
this.args.onSelect(null);
|
||||
}
|
||||
|
||||
@@ -94,7 +98,7 @@ export default class PlacesSidebar extends Component {
|
||||
|
||||
try {
|
||||
const savedPlace = await this.storage.storePlace(placeData);
|
||||
console.log('Place saved:', placeData.title);
|
||||
console.debug('Place saved:', placeData.title);
|
||||
|
||||
// Notify parent to refresh map bookmarks
|
||||
if (this.args.onBookmarkChange) {
|
||||
@@ -121,7 +125,7 @@ export default class PlacesSidebar extends Component {
|
||||
async updateBookmark(updatedPlace) {
|
||||
try {
|
||||
const savedPlace = await this.storage.updatePlace(updatedPlace);
|
||||
console.log('Place updated:', savedPlace.title);
|
||||
console.debug('Place updated:', savedPlace.title);
|
||||
|
||||
// Notify parent to refresh map/lists
|
||||
if (this.args.onBookmarkChange) {
|
||||
@@ -180,13 +184,14 @@ 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>
|
||||
"Point of Interest"
|
||||
)}}</div>
|
||||
</button>
|
||||
</li>
|
||||
{{/each}}
|
||||
@@ -194,6 +199,15 @@ export default class PlacesSidebar extends Component {
|
||||
{{else}}
|
||||
<p class="empty-state">No places found nearby.</p>
|
||||
{{/if}}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-outline create-place"
|
||||
{{on "click" this.createNewPlace}}
|
||||
>
|
||||
<Icon @name="plus" @size={{18}} @color="#007bff" />
|
||||
Create new place
|
||||
</button>
|
||||
{{/if}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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,6 @@ export default class Router extends EmberRouter {
|
||||
|
||||
Router.map(function () {
|
||||
this.route('place', { path: '/place/:place_id' });
|
||||
this.route('place.new', { path: '/place/new' });
|
||||
this.route('search');
|
||||
});
|
||||
|
||||
@@ -11,7 +11,7 @@ export default class PlaceRoute extends Route {
|
||||
|
||||
if (id.startsWith('osm:node:') || id.startsWith('osm:way:')) {
|
||||
const [, type, osmId] = id.split(':');
|
||||
console.log(`Fetching explicit OSM ${type}:`, osmId);
|
||||
console.debug(`Fetching explicit OSM ${type}:`, osmId);
|
||||
return this.loadOsmPlace(osmId, type);
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ export default class PlaceRoute extends Route {
|
||||
let bookmark = this.storage.findPlaceById(id);
|
||||
|
||||
if (bookmark) {
|
||||
console.log('Found in bookmarks:', bookmark.title);
|
||||
console.debug('Found in bookmarks:', bookmark.title);
|
||||
return bookmark;
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ export default class PlaceRoute extends Route {
|
||||
async waitForSync() {
|
||||
if (this.storage.initialSyncDone) return;
|
||||
|
||||
console.log('Waiting for initial storage sync...');
|
||||
console.debug('Waiting for initial storage sync...');
|
||||
const timeout = 5000;
|
||||
const start = Date.now();
|
||||
|
||||
@@ -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() {
|
||||
|
||||
30
app/routes/place/new.js
Normal file
30
app/routes/place/new.js
Normal file
@@ -0,0 +1,30 @@
|
||||
import Route from '@ember/routing/route';
|
||||
import { service } from '@ember/service';
|
||||
|
||||
export default class PlaceNewRoute extends Route {
|
||||
@service mapUi;
|
||||
|
||||
queryParams = {
|
||||
lat: { refreshModel: true },
|
||||
lon: { refreshModel: true },
|
||||
};
|
||||
|
||||
model(params) {
|
||||
return {
|
||||
lat: parseFloat(params.lat),
|
||||
lon: parseFloat(params.lon),
|
||||
};
|
||||
}
|
||||
|
||||
setupController(controller, model) {
|
||||
super.setupController(controller, model);
|
||||
if (model.lat && model.lon) {
|
||||
this.mapUi.updateCreationCoordinates(model.lat, model.lon);
|
||||
}
|
||||
this.mapUi.startCreating();
|
||||
}
|
||||
|
||||
deactivate() {
|
||||
this.mapUi.stopCreating();
|
||||
}
|
||||
}
|
||||
116
app/routes/search.js
Normal file
116
app/routes/search.js
Normal file
@@ -0,0 +1,116 @@
|
||||
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);
|
||||
|
||||
// Get cached/saved places in search radius
|
||||
const localMatches = this.storage.savedPlaces.filter((p) => {
|
||||
const dist = getDistance(lat, lon, p.lat, p.lon);
|
||||
return dist <= searchRadius;
|
||||
});
|
||||
|
||||
// Add local matches to the list if they aren't already there
|
||||
// We use osmId to deduplicate if possible
|
||||
localMatches.forEach((local) => {
|
||||
const exists = pois.find(
|
||||
(poi) =>
|
||||
(local.osmId && poi.osmId === local.osmId) ||
|
||||
(poi.id && poi.id === local.id)
|
||||
);
|
||||
|
||||
if (!exists) {
|
||||
pois.push(local);
|
||||
}
|
||||
});
|
||||
|
||||
// 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,9 @@ import { tracked } from '@glimmer/tracking';
|
||||
|
||||
export default class MapUiService extends Service {
|
||||
@tracked selectedPlace = null;
|
||||
@tracked isSearching = false;
|
||||
@tracked isCreating = false;
|
||||
@tracked creationCoordinates = null;
|
||||
|
||||
selectPlace(place) {
|
||||
this.selectedPlace = place;
|
||||
@@ -11,4 +14,27 @@ export default class MapUiService extends Service {
|
||||
clearSelection() {
|
||||
this.selectedPlace = null;
|
||||
}
|
||||
|
||||
startSearch() {
|
||||
this.isSearching = true;
|
||||
this.isCreating = false;
|
||||
}
|
||||
|
||||
stopSearch() {
|
||||
this.isSearching = false;
|
||||
}
|
||||
|
||||
startCreating() {
|
||||
this.isCreating = true;
|
||||
this.isSearching = false;
|
||||
}
|
||||
|
||||
stopCreating() {
|
||||
this.isCreating = false;
|
||||
this.creationCoordinates = null;
|
||||
}
|
||||
|
||||
updateCreationCoordinates(lat, lon) {
|
||||
this.creationCoordinates = { lat, lon };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.debug('Returning cached Overpass results for:', queryKey);
|
||||
return this.cachedResults;
|
||||
}
|
||||
|
||||
// Cancel previous request if it exists
|
||||
if (this.controller) {
|
||||
this.controller.abort();
|
||||
@@ -33,10 +43,16 @@ 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');
|
||||
console.debug('Overpass request aborted');
|
||||
return [];
|
||||
}
|
||||
throw e;
|
||||
@@ -62,7 +78,7 @@ out center;
|
||||
const res = await fetch(url, options);
|
||||
|
||||
if (!res.ok && retries > 0 && [502, 503, 504, 429].includes(res.status)) {
|
||||
console.log(
|
||||
console.warn(
|
||||
`Overpass request failed with ${res.status}. Retrying... (${retries} left)`
|
||||
);
|
||||
await new Promise((r) => setTimeout(r, 1000));
|
||||
@@ -72,7 +88,7 @@ out center;
|
||||
return res;
|
||||
} catch (e) {
|
||||
if (retries > 0 && e.name !== 'AbortError') {
|
||||
console.log(`Retrying Overpass request... (${retries} left)`);
|
||||
console.debug(`Retrying Overpass request... (${retries} left)`);
|
||||
await new Promise((r) => setTimeout(r, 1000));
|
||||
return this.fetchWithRetry(url, options, retries - 1);
|
||||
}
|
||||
|
||||
@@ -23,7 +23,6 @@ export default class StorageService extends Service {
|
||||
|
||||
constructor() {
|
||||
super(...arguments);
|
||||
console.log('ohai');
|
||||
|
||||
this.rs = new RemoteStorage({
|
||||
modules: [Places],
|
||||
@@ -45,13 +44,11 @@ export default class StorageService extends Service {
|
||||
});
|
||||
|
||||
this.rs.on('connected', () => {
|
||||
console.debug('Remote storage connected');
|
||||
this.connected = true;
|
||||
this.userAddress = this.rs.remote.userAddress;
|
||||
});
|
||||
|
||||
this.rs.on('disconnected', () => {
|
||||
console.debug('Remote storage disconnected');
|
||||
this.connected = false;
|
||||
this.userAddress = null;
|
||||
this.placesInView = [];
|
||||
@@ -77,18 +74,7 @@ export default class StorageService extends Service {
|
||||
handlePlaceChange(event) {
|
||||
const { newValue, relativePath } = event;
|
||||
|
||||
// Remove old entry if exists
|
||||
// The relativePath is like "geohash/geohash/ULID" or just "ULID" depending on structure.
|
||||
// Our structure is <2-char>/<2-char>/<id>.
|
||||
// But let's rely on the ID inside the object if possible, or extract from path.
|
||||
|
||||
// We can't easily identify the ID from just relativePath without parsing logic if it's nested.
|
||||
// However, for deletions (newValue is undefined), we might need the ID.
|
||||
// Fortunately, our objects (newValue) contain the ID.
|
||||
|
||||
// If it's a deletion, we need to find the object in our array to remove it.
|
||||
// Since we don't have the ID in newValue (it's null), we rely on `relativePath`.
|
||||
// Let's assume the filename is the ID.
|
||||
// Extract ID from path (structure: <2-char>/<2-char>/<id>)
|
||||
const pathParts = relativePath.split('/');
|
||||
const id = pathParts[pathParts.length - 1];
|
||||
|
||||
@@ -128,7 +114,7 @@ export default class StorageService extends Service {
|
||||
|
||||
// Recalculate prefixes for the current view
|
||||
const required = getGeohashPrefixesInBbox(this.currentBbox);
|
||||
console.log('Reloading view due to changes, prefixes:', required);
|
||||
console.debug('Reloading view due to changes, prefixes:', required);
|
||||
|
||||
// Force load these prefixes (bypassing the 'already loaded' check in loadPlacesInBounds)
|
||||
this.loadAllPlaces(required);
|
||||
@@ -144,20 +130,15 @@ export default class StorageService extends Service {
|
||||
);
|
||||
|
||||
if (missingPrefixes.length === 0) {
|
||||
// console.log('All prefixes already loaded for this view');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('Loading new prefixes:', missingPrefixes);
|
||||
console.debug('Loading new prefixes:', missingPrefixes);
|
||||
|
||||
// 3. Load places for only the new prefixes
|
||||
await this.loadAllPlaces(missingPrefixes);
|
||||
|
||||
// 4. Update our tracked list of loaded prefixes
|
||||
// Using assignment to trigger reactivity if needed, though simple push/mutation might suffice
|
||||
// depending on usage. Tracked arrays need reassignment or specific Ember array methods
|
||||
// if we want to observe the array itself, but here we just check inclusion.
|
||||
// Let's do a reassignment to be safe and clean.
|
||||
this.loadedPrefixes = [...this.loadedPrefixes, ...missingPrefixes];
|
||||
this.currentBbox = bbox;
|
||||
}
|
||||
@@ -195,7 +176,7 @@ export default class StorageService extends Service {
|
||||
} else {
|
||||
if (!prefixes) this.placesInView = [];
|
||||
}
|
||||
console.log('Loaded saved places:', this.placesInView.length);
|
||||
console.debug('Loaded saved places:', this.placesInView.length);
|
||||
} catch (e) {
|
||||
console.error('Failed to load places:', e);
|
||||
}
|
||||
@@ -216,29 +197,56 @@ export default class StorageService extends Service {
|
||||
|
||||
async storePlace(placeData) {
|
||||
const savedPlace = await this.places.store(placeData);
|
||||
// Only append if not already there (handlePlaceChange might also fire)
|
||||
|
||||
// Optimistic Update: Global List
|
||||
if (!this.savedPlaces.some((p) => p.id === savedPlace.id)) {
|
||||
this.savedPlaces = [...this.savedPlaces, savedPlace];
|
||||
} else {
|
||||
// Update if exists
|
||||
this.savedPlaces = this.savedPlaces.map((p) =>
|
||||
p.id === savedPlace.id ? savedPlace : p
|
||||
);
|
||||
}
|
||||
|
||||
// Optimistic Update: Map View (same logic as Global List)
|
||||
if (!this.placesInView.some((p) => p.id === savedPlace.id)) {
|
||||
this.placesInView = [...this.placesInView, savedPlace];
|
||||
} else {
|
||||
this.placesInView = this.placesInView.map((p) =>
|
||||
p.id === savedPlace.id ? savedPlace : p
|
||||
);
|
||||
}
|
||||
|
||||
return savedPlace;
|
||||
}
|
||||
|
||||
async updatePlace(placeData) {
|
||||
const savedPlace = await this.places.store(placeData);
|
||||
|
||||
// Update local list
|
||||
// Optimistic Update: Global List
|
||||
const index = this.savedPlaces.findIndex((p) => p.id === savedPlace.id);
|
||||
if (index !== -1) {
|
||||
const newPlaces = [...this.savedPlaces];
|
||||
newPlaces[index] = savedPlace;
|
||||
this.savedPlaces = newPlaces;
|
||||
}
|
||||
|
||||
// Update Map View
|
||||
this.placesInView = this.placesInView.map((p) =>
|
||||
p.id === savedPlace.id ? savedPlace : p
|
||||
);
|
||||
|
||||
return savedPlace;
|
||||
}
|
||||
|
||||
async removePlace(place) {
|
||||
await this.places.remove(place.id, place.geohash);
|
||||
|
||||
// Update both lists
|
||||
this.savedPlaces = this.savedPlaces.filter((p) => p.id !== place.id);
|
||||
if (this.placesInView.length > 0) {
|
||||
this.placesInView = this.placesInView.filter((p) => p.id !== place.id);
|
||||
}
|
||||
}
|
||||
|
||||
@action
|
||||
|
||||
@@ -11,6 +11,7 @@ body {
|
||||
margin: 0;
|
||||
font-family: 'Noto Serif', sans-serif;
|
||||
font-size: 16px;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
#root,
|
||||
@@ -96,7 +97,7 @@ body {
|
||||
.user-avatar-placeholder {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
background: #333;
|
||||
background: #2a3743;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -192,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;
|
||||
@@ -402,7 +402,6 @@ body {
|
||||
.place-type {
|
||||
color: #666;
|
||||
font-size: 0.85rem;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.back-btn {
|
||||
@@ -436,7 +435,6 @@ body {
|
||||
.place-details .place-type {
|
||||
color: #666;
|
||||
font-size: 0.9rem;
|
||||
text-transform: capitalize;
|
||||
margin: 0 0 1rem;
|
||||
}
|
||||
|
||||
@@ -651,6 +649,63 @@ span.icon {
|
||||
}
|
||||
}
|
||||
|
||||
/* Map Crosshair for "Create Place" mode */
|
||||
.map-crosshair {
|
||||
position: absolute;
|
||||
/* Default Center */
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
transform: translate(-50%, -50%);
|
||||
color: #333;
|
||||
pointer-events: none;
|
||||
z-index: 2000;
|
||||
display: none;
|
||||
transition:
|
||||
top 0.3s ease,
|
||||
left 0.3s ease;
|
||||
}
|
||||
|
||||
.map-crosshair.visible {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Sidebar is open (Desktop: Left 300px) */
|
||||
/* We want to center in the remaining space (width - 300px) */
|
||||
/* Center X = 300 + (width - 300) / 2 = 300 + width/2 - 150 = width/2 + 150 */
|
||||
/* So shift left by 150px from center */
|
||||
.map-container.sidebar-open .map-crosshair {
|
||||
left: calc(50% + 150px);
|
||||
}
|
||||
|
||||
@media (width <= 768px) {
|
||||
/* Sidebar/Bottom Sheet is open (Mobile: Bottom 50%) */
|
||||
/* Center Y = (height/2) / 2 = height/4 = 25% */
|
||||
.map-container.sidebar-open .map-crosshair {
|
||||
left: 50%; /* Reset desktop shift */
|
||||
top: 25%;
|
||||
}
|
||||
}
|
||||
|
||||
.helper-text {
|
||||
background: #eef4fc;
|
||||
color: #1a5c9b;
|
||||
padding: 0.75rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.9rem;
|
||||
margin-top: 0;
|
||||
margin-bottom: 1rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
button.create-place {
|
||||
width: 100%;
|
||||
margin: 1.5rem auto;
|
||||
}
|
||||
|
||||
@media (width <= 768px) {
|
||||
.sidebar {
|
||||
width: 100%;
|
||||
|
||||
@@ -1,61 +1,38 @@
|
||||
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 === 'place.new' ||
|
||||
this.router.currentRouteName === 'search'
|
||||
);
|
||||
}
|
||||
|
||||
constructor() {
|
||||
super(...arguments);
|
||||
console.log('Application component constructed');
|
||||
console.debug('Application component constructed');
|
||||
// Access the service to ensure it is instantiated
|
||||
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 +43,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 +81,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;
|
||||
|
||||
@@ -62,7 +63,7 @@ export default class PlaceTemplate extends Component {
|
||||
|
||||
@action
|
||||
handleUpdate(newPlace) {
|
||||
console.log('Updating local place state:', newPlace);
|
||||
console.debug('Updating local place state:', newPlace);
|
||||
this.localPlace = newPlace;
|
||||
this.storage.notifyChange();
|
||||
}
|
||||
@@ -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}}
|
||||
/>
|
||||
|
||||
83
app/templates/place/new.gjs
Normal file
83
app/templates/place/new.gjs
Normal file
@@ -0,0 +1,83 @@
|
||||
import Component from '@glimmer/component';
|
||||
import { service } from '@ember/service';
|
||||
import { action } from '@ember/object';
|
||||
import { on } from '@ember/modifier';
|
||||
import PlaceEditForm from '#components/place-edit-form';
|
||||
import Icon from '#components/icon';
|
||||
|
||||
export default class PlaceNewTemplate extends Component {
|
||||
@service router;
|
||||
@service storage;
|
||||
@service mapUi;
|
||||
|
||||
get initialPlace() {
|
||||
return {
|
||||
title: '',
|
||||
description: '',
|
||||
};
|
||||
}
|
||||
|
||||
@action
|
||||
close() {
|
||||
this.router.transitionTo('index');
|
||||
}
|
||||
|
||||
@action
|
||||
async savePlace(changes) {
|
||||
try {
|
||||
// Use coordinates from Map UI (which tracks the crosshair center)
|
||||
// Fallback to URL params if map state isn't ready
|
||||
const center = this.mapUi.creationCoordinates || {
|
||||
lat: this.args.model.lat,
|
||||
lon: this.args.model.lon,
|
||||
};
|
||||
|
||||
const lat = parseFloat(center.lat.toFixed(6));
|
||||
const lon = parseFloat(center.lon.toFixed(6));
|
||||
|
||||
const placeData = {
|
||||
title: changes.title || 'Untitled Place',
|
||||
description: changes.description,
|
||||
lat: lat,
|
||||
lon: lon,
|
||||
tags: [],
|
||||
osmTags: {},
|
||||
};
|
||||
|
||||
const savedPlace = await this.storage.storePlace(placeData);
|
||||
console.debug('Created private place:', savedPlace.title);
|
||||
|
||||
// Transition to the new place
|
||||
this.router.replaceWith('place', savedPlace);
|
||||
} catch (e) {
|
||||
console.error('Failed to create place:', e);
|
||||
alert('Failed to create place: ' + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
<template>
|
||||
<div class="sidebar">
|
||||
<div class="sidebar-header">
|
||||
<h2><Icon @name="plus-circle" @size={{20}} @color="#ea4335" />
|
||||
New Place</h2>
|
||||
<button type="button" class="close-btn" {{on "click" this.close}}><Icon
|
||||
@name="x"
|
||||
@size={{20}}
|
||||
@color="#333"
|
||||
/></button>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-content">
|
||||
<p class="helper-text">
|
||||
Drag the map to position the crosshair.
|
||||
</p>
|
||||
|
||||
<PlaceEditForm
|
||||
@place={{this.initialPlace}}
|
||||
@onSave={{this.savePlace}}
|
||||
@onCancel={{this.close}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
}
|
||||
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.10",
|
||||
"version": "1.10.1",
|
||||
"private": true,
|
||||
"description": "Unhosted maps app",
|
||||
"repository": {
|
||||
|
||||
@@ -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-D53xPL_H.css
Normal file
1
release/assets/main-D53xPL_H.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
@@ -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-Din37YgL.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/main-iBIZAPnF.css">
|
||||
<script type="module" crossorigin src="/assets/main-Dep3TjPE.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/main-D53xPL_H.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",
|
||||
|
||||
Reference in New Issue
Block a user