Compare commits

...

10 Commits

Author SHA1 Message Date
aa99e5d766 Add icons for all quick search categories 2026-03-20 17:26:03 +04:00
5fd4ebe184 Centrally define filled icons
So we don't have to manually pass the option everywhere
2026-03-20 16:55:19 +04:00
f2a2d910a0 WIP Search places by category 2026-03-20 16:43:57 +04:00
6b37508f66 Merge pull request 'Disable edit button while editing' (#34) from ui/edit_button into master
All checks were successful
CI / Lint (push) Successful in 50s
CI / Test (push) Successful in 1m0s
Reviewed-on: #34
2026-03-18 15:13:15 +00:00
8106009677 Disable edit button while editing
All checks were successful
CI / Lint (pull_request) Successful in 51s
CI / Test (pull_request) Successful in 1m0s
Release Drafter / Update release notes draft (pull_request) Successful in 19s
2026-03-18 19:09:59 +04:00
07489c43a4 Merge pull request 'Add Pinhead iconset, icon for cuisine' (#33) from feature/pinhead_icons into master
All checks were successful
CI / Lint (push) Successful in 49s
CI / Test (push) Successful in 1m0s
Reviewed-on: #33
2026-03-18 14:57:22 +00:00
a4e375cb51 Add Pinhead info to FOSS table in About section
All checks were successful
CI / Lint (pull_request) Successful in 51s
CI / Test (pull_request) Successful in 1m0s
Release Drafter / Update release notes draft (pull_request) Successful in 19s
2026-03-18 18:34:49 +04:00
b680769eac Replace "cuisine" with icon in place details 2026-03-18 18:17:17 +04:00
4a609c8388 Add pinhead icons
https://pinhead.ink
2026-03-18 18:16:47 +04:00
cfcaaea3ec Update status doc
All checks were successful
CI / Lint (push) Successful in 49s
CI / Test (push) Successful in 57s
2026-03-18 17:42:50 +04:00
17 changed files with 466 additions and 146 deletions

View File

@@ -1,156 +1,74 @@
# Project Status: Marco
**Last Updated:** Tue Feb 24 2026
**Last Updated:** Wed Mar 18 2026
## Project Context
We are building **Marco**, a decentralized maps application using **Ember.js** (Octane/Polaris edition with GJS/GLIMMER), **Vite**, and **OpenLayers**. The core feature is storing place bookmarks in **RemoteStorage.js**, using a custom module structure.
We are building **Marco**, a decentralized maps application using **Ember.js** (Octane/Polaris), **Vite**, and **OpenLayers**. The core feature is storing place bookmarks in **RemoteStorage.js**.
## What We Have Done
### 1. Map Integration
- Set up OpenLayers in `app/components/map.gjs` (class-based component).
- Switched tiles to **OpenFreeMap Liberty** style (supports vector POIs).
- Implemented a hybrid click handler:
- Detects clicks on visual vector tiles.
- Falls back to fetching authoritative data from an **Overpass API** service.
- **Logic Upgrade:** Map intelligently detects if _any_ sidebar/pane is open and handles outside clicks to close them instead of initiating new searches.
- **Optimization:** Added **10px hit tolerance** for easier tapping on mobile devices.
- **Visuals:** Increased bookmark marker size (Radius 9px) and added a subtle drop shadow.
- **Feedback:** Implemented a "pulse" animation (via OpenLayers Overlay) at the click location to visualize the search radius (30m/50m).
- **Mobile UX:**
- **Touch:** Disabled browser tap highlights (`-webkit-tap-highlight-color: transparent`) to prevent blue flashing on Android.
- **Scroll:** Disabled "pull-to-refresh" (`overscroll-behavior: none`) on the body to prevent accidental reloads while keeping the sidebar scrollable (`contain`).
- **Auto-Pan:** On mobile screens, if a selected pin is obscured by the bottom sheet, the map automatically pans to center the pin in the visible top half of the screen.
- **Controls:** Fixed positioning of "Locate" and "Rotate" buttons on mobile by correcting CSS `inset` syntax.
- **iOS Polish:**
- Prevented input auto-zoom by ensuring `.form-control` font size is `1rem` (16px).
- Added `-webkit-text-size-adjust: 100%` to prevent text inflation on rotation.
- Set base `body` font size to `16px`.
- **Geolocation ("Locate Me"):**
- Implemented a "Locate Me" button with robust tracking logic.
- **Dynamic Zoom:** Automatically zooms to a level where the accuracy circle covers ~10% of the map (fallback logic handles missing accuracy data).
- **Smart Pulse:** Displays a pulsing blue circle during the search phase.
- **Auto-Stop:** Pulse and tracking automatically stop when high accuracy (≤20m) is achieved or after a 10s timeout.
- **Persistence:** Saves and restores map center and zoom level using `localStorage` (key: `marco:map-view`).
- **Controls:** Enabled standard OpenLayers Rotate control (re-north) and custom Locate control.
- **Pin Animation:** Selected pins are highlighted with a custom **Red Pin** overlay that drops in with an animation. The center dot is styled as a solid dark red circle (`#b31412`).
- **Smart Zoom:** Implemented `zoomToBbox` to automatically fit complex geometries (ways/relations) within the visible viewport.
- **Dynamic Padding:** Calculates padding based on active UI elements (Sidebar on Desktop, Bottom Sheet on Mobile) to ensure the geometry is perfectly centered in the _visible_ map area.
- **Data Processing:** `OsmService` now calculates bounding boxes for ways and relations by aggregating member node coordinates.
- **Geometry Rendering:**
- **Outlines:** Implemented distinct blue outlines for selected OSM `ways` (Polygons) and `relations` (MultiLineStrings/Polygons) to clearly visualize boundaries.
- **Data Fetching:** Enhanced routing to fetch full geometry data on-demand if the initial search result (e.g., from Photon) lacks it, ensuring outlines are always available.
- **Vector Tiles:** Using **OpenFreeMap Liberty** style with a hybrid click handler (Visual Tiles + Overpass API fallback).
- **Smart Interaction:**
- **Hit Tolerance:** 10px buffer for easier mobile tapping.
- **Auto-Pan:** Selected pins automatically center in the visible area (respecting bottom sheets/sidebars).
- **Smart Zoom:** `zoomToBbox` fits complex geometries (ways/relations) with dynamic padding, only zooming out to fit.
- **Visuals:** Custom "Red Pin" overlay with drop animation. Selected OSM ways/relations show distinct blue outlines.
- **Geolocation:** Robust "Locate Me" with dynamic zoom and accuracy visualization.
### 2. RemoteStorage Module (`@remotestorage/module-places`)
- Created a custom TypeScript module in `vendor/remotestorage-module-places/`.
- **Schema:** `place` object containing `id` (ULID), `title`, `lat`, `lon`, `geohash`, `osmId`, `url`, etc.
- **Storage Path:** Nested `<2-char>/<2-char>/<id>` (based on geohash) for scalability.
- **API:**
- `getPlaces(prefixes?)`: efficient partial loading of specific sectors (or full recursive scan if no prefixes provided).
- Uses `getListing` for directory traversal and `getAll` for object retrieval.
- configured with `maxAge: false` to ensure data freshness.
- **Dependencies:** Uses `ulid` and `latlon-geohash` internally.
- **Custom Module:** Handles `place` objects with Geohash-based partitioning (`<2-char>/<2-char>/<id>`).
- **Optimization:** Supports efficient spatial querying via prefix loading.
- **Lists Support:** Manages collection-based organization (e.g., "To Visit", "Favorites").
### 3. App Infrastructure & Build
### 3. App Infrastructure
- **Services:**
- `storage.js`: Initializes RemoteStorage, claims access, enables caching, and sets up the widget. Consumes the new `getPlaces` API.
- **Optimization:** Implemented **Debounced Reload** (200ms) for bookmark updates to handle rapid change events efficiently.
- **Optimization:** Correctly handles deletion/updates by clearing stale data for reloaded geohash sectors.
- `osm.js`: Fetches nearby POIs from Overpass API.
- **Configurable:** Now supports dynamic API endpoints via `SettingsService`.
- **Reliability:** Implemented `fetchWithRetry` to handle HTTP 504/502/503 timeouts and 429 rate limits, in addition to network errors.
- **Caching:** Implemented in-memory cache for repeated `getNearbyPois` requests (same lat/lon/radius) to enable instant "Back" navigation.
- `settings.js`: Manages user preferences (currently Overpass API provider) persisted to `localStorage`.
- `storage.js`: Manages RemoteStorage, caching, and the new **Lists** feature (`to-go`, `to-do`).
- `osm.js`: Fetches/caches POIs from Overpass API (configurable endpoints).
- `settings.js`: Persists user preferences (e.g., API provider).
- **UI Components:**
- `places-sidebar.gjs`: Displays a list of nearby POIs.
- **Layout:** Responsive design that transforms into a **Bottom Sheet** (50% height) on mobile screens (`<=768px`) with rounded corners and upward shadow.
- `place-details.gjs`: Dedicated component for displaying rich place information.
- **Features:** Icons (via `feather-icons`), Address, Phone, Website, Opening Hours, Cuisine, Wikipedia.
- **Layout:** Polished UI with distinct sections for Actions and Meta info.
- `app-header.gjs`: Transparent header with "Menu" button (Settings) and User Avatar (Login).
- `settings-pane.gjs`: Sidebar component for app info ("About" section) and settings.
- **Features:** Dropdown to select Overpass API provider (bke.ro, overpass-api.de, private.coffee).
- **Mobile:** Renders as a 2/3 height bottom sheet on mobile.
- **Z-Index:** Configured to overlay the Places sidebar correctly (`z-index: 3200`).
- **Geo Utils:**
- `app/utils/geo.js`: Haversine distance calculations.
- `app/utils/geohash-coverage.js`: Logic to calculate required 4-char geohash prefixes for a given bounding box.
- **Format Utils:**
- `app/utils/format-text.js` & `humanize-osm-tag` helper: Standardized logic (Title Case, space replacement) for displaying OSM tags like `guest_house` -> "Guest House".
- **Tag refinement:** Improved logic for handling generic tags (e.g., `building=yes`). The UI now intelligently displays the key ("Building") instead of the value ("Yes") for better readability.
- **Localization:** Added basic `navigator.languages` support to `getLocalizedName` for preferring local names when available.
- **Build & DevOps:**
- **Icon Generation:** Added `build:icons` script using `magick` and `rsvg-convert` to automate PNG generation from SVG.
- **Dependencies:** Documented system requirements (ImageMagick, librsvg) in `README.md`.
- **Ember CLI:** Added as dev dependency to support generator commands.
- **License:** Added AGPLv3 license.
- **Responsive Layout:** Sidebar transforms into a Bottom Sheet on mobile.
- **Place Details:** Rich info (Address, Socials, Opening Hours) with distinct "Actions" and "Meta" sections.
- **App Menu:** Comprehensive settings and about section, implemented as a secondary sidebar.
- **CI/CD:** Gitea Actions for automated testing and release drafting.
### 4. Routing & Architecture (Refactored)
### 4. Routing & Architecture
- **URL-Driven Architecture:** Moved from service-based state to proper route-based state management.
- `/search?lat=...&lon=...&q=...`: Displays search results list.
- `/place/:place_id`: Displays details for a specific place (OSM POI or Bookmark).
- **Heuristic Navigation:** The `search` route implements "visual click matching" logic. If a search yields a direct match (exact name or very close proximity), it automatically redirects to the `/place/` route, skipping the list view.
- **Back Button Support:** Browser history works correctly. Navigating "Back" from a place returns to the cached search results instantly without network requests.
- **Explicit URLs:** Routes support specific OSM entities via `/place/osm:node:<id>` and `/place/osm:way:<id>`, distinguishing them from local bookmarks (ULIDs).
- **Smart Linking:** The `showPlaces` action intercepts search results and automatically resolves them to existing **Bookmarks** if a match is found (via `storage.findPlaceById`). This ensures the app navigates to the persistent Bookmark URL (ULID) and correctly reflects the "Saved" status in the UI instead of treating it as a new generic OSM place.
- **Data Normalization:** Refactored `OsmService` to return normalized objects (`osmTags`, `osmType`) for all queries. This ensures consistent data structures between fresh Overpass results and saved bookmarks throughout the app.
- **URL-Driven:** `/search` (list) and `/place/:id` (details) routes.
- **Smart Navigation:**
- Direct hits redirect to details.
- Search results automatically resolve to existing **Bookmarks**.
- "Back" navigation returns to cached search results instantly.
### 5. Creation & Editing Workflow
### 5. Features
- **Create Place:**
- Implemented `/place/new` route for creating new private places.
- **UX:** Map displays a central crosshair for precise location selection.
- **Mobile Optimization:**
- Disabled map inertia (`kinetic: false`) to ensure the map stops exactly where the finger releases.
- `PlaceEditForm` conditionally disables autofocus on mobile screens (`<= 768px`) to prevent the onscreen keyboard from obscuring the map view immediately.
- Responsive crosshair sizing (48px desktop / 24px mobile).
- **Persistence:** Form data (Title, Description) and Map coordinates are securely saved to RemoteStorage via `storage.storePlace`.
### 6. Search Functionality
- **Provider:** Integrated **Photon API** (by Komoot) via `app/services/photon.js` for high-quality, typo-tolerant OpenStreetMap search.
- **UI:** `SearchBoxComponent` implements a responsive search bar with instant autocomplete.
- **Debounced Input:** 300ms delay to prevent excessive API calls.
- **Location Bias:** Automatically biases search results towards the current map center to show relevant local places first.
- **Direct Navigation:** Selecting a result with a valid OSM ID navigates directly to the specific place details (`/place/osm:type:id`).
- **Resilience:** Implemented retry logic (exponential backoff/fixed delay) for network errors and rate limits (429).
- **Data Normalization:** Search results are normalized to match the internal POI schema, ensuring consistent rendering across Search and Map views.
- **Search:** Typo-tolerant **Photon API** integration with location bias and debounce.
- **Creation & Editing:**
- "Crosshair" mode for precise location picking.
- Edit Title/Description for saved places.
- **Lists:** Users can add places to default lists ("To Go", "To Do") directly from the details view.
- **Socials:** Place details now include Email, Facebook, and Instagram links.
- **Data Sync:** Auto-refreshes OSM data (coords/tags) for saved places on view, preserving custom titles.
## Current State
- **Repo:** The app runs via `pnpm start`.
- **Repo:** Runs via `pnpm start`.
- **Workflow:**
1. User pans map -> `moveend` triggers `storage.loadPlacesInBounds`.
2. User clicks map -> Route transition to `/search` -> "Pulse" animation -> hybrid hit detection (Visual Tile vs Overpass).
3. **Navigation:**
- If direct match: Redirect to `/place/:id`.
- If multiple results: Show `/search` list view.
4. Sidebar displays details via `<PlaceDetails>` component (Bottom sheet on mobile).
5. **Creation:** User clicks "Create Place" -> Enters creation mode (crosshair) -> Positions map -> Enters details -> Save.
6. **Persistence:** RemoteStorage change event -> Debounced reload updates the map reactive-ly.
7. **Editing:** User can edit the Title and Description of saved bookmarks via an "Edit" button in the details view.
8. **Settings:** User can change the Overpass API provider via the new Settings menu.
1. **Explore:** Pan/Zoom loads bookmarks from RemoteStorage.
2. **Search:** Query via Photon -> List or Direct Result.
3. **View:** Details pane (Sidebar/Bottom Sheet) shows rich info + social links.
4. **Action:**
- **Save:** Persist to RemoteStorage.
- **Organize:** Add to "To Go" / "To Do" lists.
- **Edit:** Custom Title/Description.
5. **Sync:** Background check updates OSM data if changed.
## Files Currently in Focus
## Next Steps
- `app/services/osm.js`
- `app/components/map.gjs`
- `app/routes/place.js`
- `app/utils/osm.js`
## Next Steps & Pending Tasks
1. **Linting & Code Quality:** Fix remaining CSS errors and address unused variables/runloop usage.
2. **Testing:** Add automated tests for the geohash coverage, retry logic, and new editing features.
3. **Performance:** Monitor performance with large datasets (thousands of bookmarks).
## Technical Constraints
- **Template Style:** Strict Mode GJS (`<template>`).
- **Package Manager:** `pnpm` for the main app, `npm` for the vendor module.
- **Visuals:** No Tailwind/Bootstrap; using custom CSS in `app/styles/app.css`.
1. **Testing:** Add automated tests for the new Lists logic and Geohash coverage.
2. **Performance:** Monitor with large datasets.
3. **Refinement:** Polish list UI and interactions.

View File

@@ -6,10 +6,12 @@ import { on } from '@ember/modifier';
import Icon from '#components/icon';
import UserMenu from '#components/user-menu';
import SearchBox from '#components/search-box';
import CategoryChips from '#components/category-chips';
export default class AppHeaderComponent extends Component {
@service storage;
@tracked isUserMenuOpen = false;
@tracked hasQuery = false;
@action
toggleUserMenu() {
@@ -21,10 +23,31 @@ export default class AppHeaderComponent extends Component {
this.isUserMenuOpen = false;
}
@action
handleQueryChange(query) {
this.hasQuery = !!query;
}
@action
handleChipSelect() {
// When a chip is selected, we might want to ensure the search box is cleared visually,
// although the route transition will happen.
// The SearchBox component manages its own state, so we rely on the route transition.
// However, if we want to clear the search box input from here, we'd need to control it.
// For now, let's just let the route change happen.
}
<template>
<header class="app-header">
<div class="header-left">
<SearchBox @onToggleMenu={{@onToggleMenu}} />
<SearchBox
@onToggleMenu={{@onToggleMenu}}
@onQueryChange={{this.handleQueryChange}}
/>
</div>
<div class="header-center {{if this.hasQuery 'searching'}}">
<CategoryChips @onSelect={{this.handleChipSelect}} />
</div>
<div class="header-right">

View File

@@ -111,6 +111,22 @@ import Icon from '#components/icon';
</a>
</td>
</tr>
<tr>
<td>
<a href="https://pinhead.ink/" target="_blank" rel="noopener">
Pinhead Icons
</a>
</td>
<td>
<a
href="https://github.com/waysidemapping/pinhead?tab=readme-ov-file#where-the-icons-are-from"
target="_blank"
rel="noopener"
>
Various
</a>
</td>
</tr>
</tbody>
</table>
</div>

View File

@@ -0,0 +1,52 @@
import Component from '@glimmer/component';
import { service } from '@ember/service';
import { action } from '@ember/object';
import { on } from '@ember/modifier';
import { fn } from '@ember/helper';
import Icon from '#components/icon';
import { POI_CATEGORIES } from '../utils/poi-categories';
export default class CategoryChipsComponent extends Component {
@service router;
@service mapUi;
get categories() {
return POI_CATEGORIES;
}
@action
searchCategory(category) {
// If passed an onSelect action, call it (e.g. to clear search box)
if (this.args.onSelect) {
this.args.onSelect(category);
}
let queryParams = { category: category.id, q: null };
if (this.mapUi.currentCenter) {
const { lat, lon } = this.mapUi.currentCenter;
queryParams.lat = parseFloat(lat).toFixed(4);
queryParams.lon = parseFloat(lon).toFixed(4);
}
this.router.transitionTo('search', { queryParams });
}
<template>
<div class="category-chips-scroll">
<div class="category-chips-container">
{{#each this.categories as |category|}}
<button
type="button"
class="category-chip"
{{on "click" (fn this.searchCategory category)}}
aria-label={{category.label}}
>
<Icon @name={{category.icon}} @size={{16}} />
<span>{{category.label}}</span>
</button>
{{/each}}
</div>
</div>
</template>
}

View File

@@ -1,6 +1,6 @@
import Component from '@glimmer/component';
import { htmlSafe } from '@ember/template';
import { getIcon } from '../utils/icons';
import { getIcon, isIconFilled } from '../utils/icons';
export default class IconComponent extends Component {
get svg() {
@@ -25,10 +25,14 @@ export default class IconComponent extends Component {
return this.args.title || '';
}
get isFilled() {
return this.args.filled || isIconFilled(this.args.name);
}
<template>
{{#if this.svg}}
<span
class="icon {{if @filled 'icon-filled'}}"
class="icon {{if this.isFilled 'icon-filled'}}"
style={{this.style}}
title={{this.title}}
>

View File

@@ -914,6 +914,7 @@ export default class MapComponent extends Component {
const [maxLon, maxLat] = toLonLat([extent[2], extent[3]]);
const bbox = { minLat, minLon, maxLat, maxLon };
this.mapUi.updateBounds(bbox);
await this.storage.loadPlacesInBounds(bbox);
this.loadBookmarks(this.storage.placesInView);

View File

@@ -305,6 +305,7 @@ export default class PlaceDetails extends Component {
type="button"
class="btn btn-outline"
title="Edit"
disabled={{this.isEditing}}
{{on "click" this.startEditing}}
>
<Icon @name="edit" @color="var(--link-color)" />
@@ -316,9 +317,11 @@ export default class PlaceDetails extends Component {
<div class="meta-info">
{{#if this.cuisine}}
<p class="cuisine-info">
<strong>Cuisine:</strong>
{{this.cuisine}}
<p class="content-with-icon">
<Icon @name="fork-and-knife" @title="Cuisine" />
<span>
{{this.cuisine}}
</span>
</p>
{{/if}}
@@ -390,7 +393,7 @@ export default class PlaceDetails extends Component {
{{#if this.wikipedia}}
<p class="content-with-icon">
<Icon @name="wikipedia" @title="Wikipedia" @filled={{true}} />
<Icon @name="wikipedia" @title="Wikipedia" />
<span>
<a
href="https://wikipedia.org/wiki/{{this.wikipedia}}"

View File

@@ -27,6 +27,10 @@ export default class SearchBoxComponent extends Component {
@action
handleInput(event) {
this.query = event.target.value;
if (this.args.onQueryChange) {
this.args.onQueryChange(this.query);
}
if (this.query.length < 2) {
this.results = [];
return;
@@ -35,6 +39,7 @@ export default class SearchBoxComponent extends Component {
this.searchTask.perform();
}
searchTask = task({ restartable: true }, async () => {
await timeout(300);
@@ -121,8 +126,10 @@ export default class SearchBoxComponent extends Component {
clear() {
this.query = '';
this.results = [];
this.router.transitionTo('index'); // Or stay on current page?
// Usually clear just clears the input.
if (this.args.onQueryChange) {
this.args.onQueryChange('');
}
this.router.transitionTo('index');
}
<template>

View File

@@ -1,10 +1,11 @@
import Controller from '@ember/controller';
export default class SearchController extends Controller {
queryParams = ['lat', 'lon', 'q', 'selected'];
queryParams = ['lat', 'lon', 'q', 'selected', 'category'];
lat = null;
lon = null;
q = null;
selected = null;
category = null;
}

View File

@@ -15,6 +15,7 @@ export default class SearchRoute extends Route {
lon: { refreshModel: true },
q: { refreshModel: true },
selected: { refreshModel: true },
category: { refreshModel: true },
};
async model(params) {
@@ -22,8 +23,37 @@ export default class SearchRoute extends Route {
const lon = params.lon ? parseFloat(params.lon) : null;
let pois = [];
// Case 0: Category Search (category parameter present)
if (params.category && lat && lon) {
// We need bounds. If we have active map state, use it.
let bounds = this.mapUi.currentBounds;
// If we don't have bounds (direct URL visit), estimate them from lat/lon/zoom(16)
// or just use a fixed box around the center.
if (!bounds) {
// Approximate 0.01 degrees ~ 1km at equator. A viewport is roughly 0.02x0.01 at zoom 16?
// Let's take a safe box of ~1km radius.
const delta = 0.01;
bounds = {
minLat: lat - delta,
maxLat: lat + delta,
minLon: lon - delta,
maxLon: lon + delta,
};
}
pois = await this.osm.getCategoryPois(bounds, params.category);
// Sort by distance from center
pois = pois
.map((p) => ({
...p,
_distance: getDistance(lat, lon, p.lat, p.lon),
}))
.sort((a, b) => a._distance - b._distance);
}
// Case 1: Text Search (q parameter present)
if (params.q) {
else if (params.q) {
// Search with Photon (using lat/lon for bias if available)
pois = await this.photon.search(params.q, lat, lon);

View File

@@ -8,6 +8,7 @@ export default class MapUiService extends Service {
@tracked creationCoordinates = null;
@tracked returnToSearch = false;
@tracked currentCenter = null;
@tracked currentBounds = null;
@tracked searchBoxHasFocus = false;
@tracked selectionOptions = {};
@tracked preventNextZoom = false;
@@ -54,4 +55,8 @@ export default class MapUiService extends Service {
updateCenter(lat, lon) {
this.currentCenter = { lat, lon };
}
updateBounds(bounds) {
this.currentBounds = bounds;
}
}

View File

@@ -1,5 +1,6 @@
import Service, { service } from '@ember/service';
import { getLocalizedName, getPlaceType } from '../utils/osm';
import { getCategoryById } from '../utils/poi-categories';
export default class OsmService extends Service {
@service settings;
@@ -76,6 +77,48 @@ out center;
}
}
async getCategoryPois(bounds, categoryId) {
const category = getCategoryById(categoryId);
if (!category || !bounds) return [];
const { minLat, minLon, maxLat, maxLon } = bounds;
// Build the query parts for each filter string and type
const queryParts = [];
// Default types if not specified (legacy fallback)
const types = category.types || ['node', 'way', 'relation'];
category.filter.forEach((filterString) => {
types.forEach((type) => {
// We ensure we only fetch named POIs to reduce noise
queryParts.push(`${type}${filterString}[~"^name"~"."];`);
});
});
const query = `
[out:json][timeout:25][bbox:${minLat},${minLon},${maxLat},${maxLon}];
(
${queryParts.join('\n ')}
);
out center;
`.trim();
// No caching for now as bounds change frequently
const url = `${this.settings.overpassApi}?data=${encodeURIComponent(query)}`;
try {
const res = await this.fetchWithRetry(url);
if (!res.ok) throw new Error('Overpass request failed');
const data = await res.json();
const results = data.elements.map(this.normalizePoi);
return results;
} catch (e) {
console.error('Category search failed', e);
return [];
}
}
normalizePoi(poi) {
const tags = poi.tags || {};
const type = getPlaceType(tags) || 'Point of Interest';

View File

@@ -70,27 +70,94 @@ body {
right: 0;
height: 60px;
padding: 0.5rem 1rem;
display: flex;
justify-content: space-between;
align-items: center;
z-index: 3000; /* Above sidebar (2000) and map */
pointer-events: none; /* Let clicks pass through to map where transparent */
/* Layout */
display: grid;
/* Desktop: 1fr auto 1fr ensures the center element is absolutely centered */
grid-template-columns: 1fr auto 1fr;
grid-template-areas: 'search chips user';
align-items: center;
gap: 1rem;
}
@media (width <= 768px) {
.app-header {
padding: 0 0.5rem;
padding: 0.5rem 0.5rem 0;
height: auto;
grid-template-columns: 1fr auto;
grid-template-areas:
'search user'
'chips chips';
row-gap: 8px; /* Increased spacing */
}
}
.header-left,
.header-right {
pointer-events: auto; /* Re-enable clicks for buttons */
.header-right,
.header-center {
pointer-events: auto; /* Re-enable clicks */
}
.header-left {
display: flex;
align-items: center;
grid-area: search;
/* Ensure it sits at the start of its grid area */
justify-self: start;
width: 100%;
}
@media (width > 768px) {
.header-left {
min-width: 300px;
max-width: 400px;
}
}
@media (width > 768px) {
.header-left {
/* Desktop: Ensure minimum width for search box so it's not squeezed */
min-width: 300px;
max-width: 350px;
}
}
.header-right {
grid-area: user;
justify-self: end;
}
.header-center {
grid-area: chips;
/* Desktop: Center the chips block in the available space */
display: flex;
justify-content: center;
min-width: 0; /* Allow shrinking */
}
/* Adjust scroll container for desktop centering */
@media (width > 768px) {
.header-center .category-chips-scroll {
width: auto;
max-width: 100%;
}
}
@media (width <= 768px) {
/* No need to reset min-width/max-width since they are only set in media query above */
.header-center {
width: 100%;
overflow: hidden;
justify-content: start;
}
/* Hide chips on mobile when searching to save space */
.header-center.searching {
display: none;
}
}
.btn-press {
@@ -603,6 +670,12 @@ abbr[title] {
gap: 0.5rem;
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
pointer-events: none;
}
.btn-outline {
background: transparent;
color: #333;
@@ -1184,3 +1257,53 @@ button.create-place {
background: #eee;
margin: 0.5rem 0;
}
/* Category Chips */
.category-chips-scroll {
width: 100%;
overflow-x: auto;
/* Add padding for shadows */
padding: 4px 0;
-webkit-overflow-scrolling: touch;
/* Hide scrollbar but keep functionality */
scrollbar-width: none; /* Firefox */
-ms-overflow-style: none; /* IE/Edge */
/* Remove top margin as spacing is handled by grid/layout */
margin-top: 0;
}
.category-chips-scroll::-webkit-scrollbar {
display: none;
}
.category-chips-container {
display: flex;
gap: 8px;
/* Padding on sides so first/last chip isn't flush with screen edge */
padding: 0 4px;
width: max-content; /* Ensure it scrolls */
}
.category-chip {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
background: white;
border: 1px solid #ddd;
border-radius: 16px; /* Pill shape */
font-size: 0.9rem;
color: #333;
cursor: pointer;
white-space: nowrap;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
transition: background-color 0.2s;
}
.category-chip:hover {
background: var(--hover-bg);
}
.category-chip:active {
background: #eee;
}

View File

@@ -27,14 +27,21 @@ 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';
import camera from '@waysidemapping/pinhead/dist/icons/camera.svg?raw';
import cupAndSaucer from '@waysidemapping/pinhead/dist/icons/cup_and_saucer.svg?raw';
import forkAndKnife from '@waysidemapping/pinhead/dist/icons/fork_and_knife.svg?raw';
import personSleepingInBed from '@waysidemapping/pinhead/dist/icons/person_sleeping_in_bed.svg?raw';
import shoppingBasket from '@waysidemapping/pinhead/dist/icons/shopping_basket.svg?raw';
import wikipedia from '../icons/wikipedia.svg?raw';
const ICONS = {
'arrow-left': arrowLeft,
activity,
bookmark,
camera,
'check-square': checkSquare,
clock,
'cup-and-saucer': cupAndSaucer,
edit,
facebook,
gift,
@@ -43,6 +50,7 @@ const ICONS = {
home,
info,
instagram,
'fork-and-knife': forkAndKnife,
'log-in': logIn,
'log-out': logOut,
mail,
@@ -50,11 +58,13 @@ const ICONS = {
'map-pin': mapPin,
menu,
navigation,
'person-sleeping-in-bed': personSleepingInBed,
phone,
plus,
server,
search,
settings,
'shopping-basket': shoppingBasket,
target,
user,
wikipedia,
@@ -62,6 +72,19 @@ const ICONS = {
zap,
};
const FILLED_ICONS = [
'fork-and-knife',
'wikipedia',
'cup-and-saucer',
'shopping-basket',
'camera',
'person-sleeping-in-bed',
];
export function getIcon(name) {
return ICONS[name];
}
export function isIconFilled(name) {
return FILLED_ICONS.includes(name);
}

View File

@@ -0,0 +1,62 @@
// This configuration defines the "Quick Search" categories available in the UI.
//
// Structure:
// - id: The URL slug used for routing (e.g. ?category=restaurants)
// - label: The human-readable name displayed in the UI
// - icon: The icon name (must be registered in app/utils/icons.js)
// - filter: An array of Overpass QL query parts.
// - Each string in the array is an independent query condition.
// - Multiple strings act as an OR condition (union of results).
export const POI_CATEGORIES = [
{
id: 'restaurants',
label: 'Restaurants',
icon: 'fork-and-knife',
filter: ['["amenity"~"^(restaurant|fast_food|food_court|pub|cafe)$"]'],
types: ['node', 'way'],
},
{
id: 'coffee',
label: 'Coffee',
icon: 'cup-and-saucer',
filter: [
'["amenity"~"^(cafe|ice_cream)$"]',
'["shop"~"^(coffee|tea)$"]',
'["cuisine"~"coffee_shop"]',
],
types: ['node', 'way'],
},
{
id: 'groceries',
label: 'Groceries',
icon: 'shopping-basket',
filter: [
'["shop"~"^(supermarket|convenience|grocery|greengrocer|bakery|butcher|deli|farm|seafood)$"]',
],
types: ['node', 'way'],
},
{
id: 'things-to-do',
label: 'Things to do',
icon: 'camera',
filter: [
'["tourism"~"^(museum|gallery|attraction|viewpoint|zoo|theme_park|aquarium|artwork)$"]',
'["amenity"~"^(cinema|theatre|arts_centre|planetarium)$"]',
'["leisure"~"^(sports_centre|stadium|water_park)$"]',
'["historic"]',
],
types: ['node', 'way', 'relation'],
},
{
id: 'accommodation',
label: 'Hotels',
icon: 'person-sleeping-in-bed',
filter: ['["tourism"~"^(hotel|hostel|motel)$"]'],
types: ['node', 'way', 'relation'],
},
];
export function getCategoryById(id) {
return POI_CATEGORIES.find((c) => c.id === id);
}

View File

@@ -102,6 +102,7 @@
"edition": "octane"
},
"dependencies": {
"@waysidemapping/pinhead": "^15.17.0",
"ember-concurrency": "^5.2.0",
"ember-lifeline": "^7.0.0"
}

8
pnpm-lock.yaml generated
View File

@@ -8,6 +8,9 @@ importers:
.:
dependencies:
'@waysidemapping/pinhead':
specifier: ^15.17.0
version: 15.17.0
ember-concurrency:
specifier: ^5.2.0
version: 5.2.0(@babel/core@7.28.6)
@@ -1651,6 +1654,9 @@ packages:
peerDependencies:
'@warp-drive/core': 5.8.1
'@waysidemapping/pinhead@15.17.0':
resolution: {integrity: sha512-XcL/0Ll+gkRIpXlO+skwd6USynA+mX3DNwqrWDMhgRmLP4DNRPTeaecK64BBxk1bB/F9Xi/9kgN6JA5zbdgejQ==}
'@xmldom/xmldom@0.8.11':
resolution: {integrity: sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw==}
engines: {node: '>=10.0.0'}
@@ -7239,6 +7245,8 @@ snapshots:
- '@glint/template'
- supports-color
'@waysidemapping/pinhead@15.17.0': {}
'@xmldom/xmldom@0.8.11': {}
abbrev@1.1.1: {}