Compare commits

...

5 Commits

Author SHA1 Message Date
e7b3b72e2f
1.9.0 2026-01-27 11:22:37 +07:00
399ad1822d
Humanize place type properly, refactor for other tags 2026-01-27 11:21:51 +07:00
104a742543
Use dark grey for all text, change theme color 2026-01-27 11:00:06 +07:00
a8dc4c81e4
Implement simple query cache for Overpass/OSM search
So when we return to the search route, we don't have to refetch
2026-01-27 09:50:41 +07:00
156280950f
Refactor search results with dedicated route 2026-01-27 09:50:26 +07:00
25 changed files with 251 additions and 170 deletions

View File

@ -21,6 +21,7 @@ export default class MapComponent extends Component {
@service osm;
@service storage;
@service mapUi;
@service router;
mapInstance;
bookmarkSource;
@ -510,6 +511,17 @@ export default class MapComponent extends Component {
}
}
// Sync the pulse animation with the UI service state
syncPulse = modifier(() => {
if (!this.searchOverlayElement) return;
if (this.mapUi.isSearching) {
this.searchOverlayElement.classList.add('active');
} else {
this.searchOverlayElement.classList.remove('active');
}
});
handleMapMove = async () => {
if (!this.mapInstance) return;
@ -546,7 +558,6 @@ export default class MapComponent extends Component {
});
let clickedBookmark = null;
let selectedFeatureName = null;
let selectedFeatureType = null;
if (features && features.length > 0) {
console.debug(`Found ${features.length} features in map layer:`);
@ -561,7 +572,6 @@ export default class MapComponent extends Component {
const props = features[0].getProperties();
if (props.name) {
selectedFeatureName = props.name;
selectedFeatureType = props.class || props.subclass;
}
}
@ -573,9 +583,7 @@ export default class MapComponent extends Component {
'Clicked bookmark while sidebar open (switching):',
clickedBookmark
);
if (this.args.onPlacesFound) {
this.args.onPlacesFound([], clickedBookmark);
}
this.router.transitionTo('place', clickedBookmark);
return;
}
@ -589,9 +597,7 @@ export default class MapComponent extends Component {
// Normal behavior (sidebar is closed)
if (clickedBookmark) {
console.log('Clicked bookmark:', clickedBookmark);
if (this.args.onPlacesFound) {
this.args.onPlacesFound([], clickedBookmark);
}
this.router.transitionTo('place', clickedBookmark);
return;
}
@ -615,76 +621,21 @@ export default class MapComponent extends Component {
this.searchOverlayElement.style.width = `${diameterInPixels}px`;
this.searchOverlayElement.style.height = `${diameterInPixels}px`;
this.searchOverlay.setPosition(event.coordinate);
this.searchOverlayElement.classList.add('active');
}
// 2. Fetch authoritative data via Overpass
try {
let pois = await this.osm.getNearbyPois(lat, lon, searchRadius);
// Start Search State
this.mapUi.startSearch();
// Sort by distance from click
pois = pois
.map((p) => {
// p is already normalized by service, so lat/lon are at top level
return {
...p,
_distance: getDistance(lat, lon, p.lat, p.lon),
};
})
.sort((a, b) => a._distance - b._distance);
let matchedPlace = null;
if (selectedFeatureName && pois.length > 0) {
// Heuristic:
// 1. Exact Name Match
matchedPlace = pois.find(
(p) =>
p.osmTags &&
(p.osmTags.name === selectedFeatureName ||
p.osmTags['name:en'] === selectedFeatureName)
);
// 2. If no exact match, look for VERY close (<=20m) and matching type
if (!matchedPlace) {
const topCandidate = pois[0];
if (topCandidate._distance <= 20) {
// Check type compatibility if available
// (visual tile 'class' is often 'cafe', osm tag is 'amenity'='cafe')
const pType =
topCandidate.osmTags.amenity ||
topCandidate.osmTags.shop ||
topCandidate.osmTags.tourism;
if (
selectedFeatureType &&
pType &&
(selectedFeatureType === pType ||
pType.includes(selectedFeatureType))
) {
console.log(
'Heuristic match found (distance + type):',
topCandidate
);
matchedPlace = topCandidate;
} else if (topCandidate._distance <= 10) {
// Even without type match, if it's super close (<=10m), it's likely the one.
console.log('Heuristic match found (proximity):', topCandidate);
matchedPlace = topCandidate;
}
}
}
}
if (this.args.onPlacesFound) {
this.args.onPlacesFound(pois, matchedPlace);
}
} catch (error) {
console.error('Failed to fetch POIs:', error);
} finally {
if (this.searchOverlayElement) {
this.searchOverlayElement.classList.remove('active');
}
// Transition to Search Route
const queryParams = {
lat: lat.toFixed(6),
lon: lon.toFixed(6),
};
if (selectedFeatureName) {
queryParams.q = selectedFeatureName;
}
this.router.transitionTo('search', { queryParams });
};
<template>
@ -693,6 +644,7 @@ export default class MapComponent extends Component {
{{this.setupMap}}
{{this.updateBookmarks}}
{{this.updateSelectedPin}}
{{this.syncPulse}}
></div>
</template>
}

View File

@ -1,7 +1,7 @@
import Component from '@glimmer/component';
import { fn } from '@ember/helper';
import { on } from '@ember/modifier';
import capitalize from '../helpers/capitalize';
import { humanizeOsmTag } from '../utils/format-text';
import Icon from '../components/icon';
import { tracked } from '@glimmer/tracking';
@ -76,14 +76,15 @@ export default class PlaceDetails extends Component {
}
get type() {
return (
const rawType =
this.tags.amenity ||
this.tags.shop ||
this.tags.tourism ||
this.tags.leisure ||
this.tags.historic ||
'Point of Interest'
);
'Point of Interest';
return humanizeOsmTag(rawType);
}
get address() {
@ -133,8 +134,7 @@ export default class PlaceDetails extends Component {
if (!this.tags.cuisine) return null;
return this.tags.cuisine
.split(';')
.map((c) => capitalize.compute([c]))
.map((c) => c.replace('_', ' '))
.map((c) => humanizeOsmTag(c))
.join(', ');
}

View File

@ -6,6 +6,7 @@ import { fn } from '@ember/helper';
import or from 'ember-truth-helpers/helpers/or';
import PlaceDetails from './place-details';
import Icon from './icon';
import humanizeOsmTag from '../helpers/humanize-osm-tag';
export default class PlacesSidebar extends Component {
@service storage;
@ -23,13 +24,6 @@ export default class PlacesSidebar extends Component {
if (this.args.onSelect) {
this.args.onSelect(null);
}
// Fallback logic: if no list available, close sidebar
if (!this.args.places || this.args.places.length === 0) {
if (this.args.onClose) {
this.args.onClose();
}
}
}
@action
@ -180,13 +174,13 @@ export default class PlacesSidebar extends Component {
place.osmTags.name:en
"Unnamed Place"
}}</div>
<div class="place-type">{{or
<div class="place-type">{{humanizeOsmTag (or
place.osmTags.amenity
place.osmTags.shop
place.osmTags.tourism
place.osmTags.leisure
place.osmTags.historic
}}</div>
)}}</div>
</button>
</li>
{{/each}}

View File

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

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

View File

@ -8,4 +8,5 @@ export default class Router extends EmberRouter {
Router.map(function () {
this.route('place', { path: '/place/:place_id' });
this.route('search');
});

View File

@ -49,6 +49,8 @@ export default class PlaceRoute extends Route {
if (model) {
this.mapUi.selectPlace(model);
}
// Stop the pulse animation if it was running (e.g. redirected from search)
this.mapUi.stopSearch();
}
deactivate() {

96
app/routes/search.js Normal file
View File

@ -0,0 +1,96 @@
import Route from '@ember/routing/route';
import { service } from '@ember/service';
import { action } from '@ember/object';
import { getDistance } from '../utils/geo';
export default class SearchRoute extends Route {
@service osm;
@service mapUi;
@service storage;
@service router;
queryParams = {
lat: { refreshModel: true },
lon: { refreshModel: true },
q: { refreshModel: true },
};
async model(params) {
// If no coordinates, we can't search
if (!params.lat || !params.lon) {
return [];
}
const lat = parseFloat(params.lat);
const lon = parseFloat(params.lon);
const searchRadius = params.q ? 30 : 50;
// Fetch POIs
let pois = await this.osm.getNearbyPois(lat, lon, searchRadius);
// Sort by distance from click
pois = pois
.map((p) => {
return {
...p,
_distance: getDistance(lat, lon, p.lat, p.lon),
};
})
.sort((a, b) => a._distance - b._distance);
// Check if any of these are already bookmarked
// We resolve them to the bookmark version if they exist
pois = pois.map((p) => {
const saved = this.storage.findPlaceById(p.osmId);
return saved || p;
});
return pois;
}
afterModel(model, transition) {
const { q } = transition.to.queryParams;
// Heuristic Match Logic (ported from MapComponent)
if (q && model.length > 0) {
let matchedPlace = null;
// 1. Exact Name Match
matchedPlace = model.find(
(p) => p.osmTags && (p.osmTags.name === q || p.osmTags['name:en'] === q)
);
// 2. High Proximity Match (<= 10m)
// Note: MapComponent had logic for <=20m + type match.
// We might want to pass the 'type' in queryParams if we want to be that precise.
// For now, let's stick to name or very close proximity.
if (!matchedPlace) {
const topCandidate = model[0];
if (topCandidate._distance <= 10) {
matchedPlace = topCandidate;
}
}
if (matchedPlace) {
// Direct transition!
this.router.replaceWith('place', matchedPlace);
return;
}
}
// Stop the pulse animation since search is done (and we are staying here)
this.mapUi.stopSearch();
}
setupController(controller, model) {
super.setupController(controller, model);
// Ensure pulse is stopped if we reach here
this.mapUi.stopSearch();
}
@action
error() {
this.mapUi.stopSearch();
return true; // Bubble error
}
}

View File

@ -3,6 +3,7 @@ import { tracked } from '@glimmer/tracking';
export default class MapUiService extends Service {
@tracked selectedPlace = null;
@tracked isSearching = false;
selectPlace(place) {
this.selectedPlace = place;
@ -11,4 +12,12 @@ export default class MapUiService extends Service {
clearSelection() {
this.selectedPlace = null;
}
startSearch() {
this.isSearching = true;
}
stopSearch() {
this.isSearching = false;
}
}

View File

@ -4,8 +4,18 @@ export default class OsmService extends Service {
@service settings;
controller = null;
cachedResults = null;
lastQueryKey = null;
async getNearbyPois(lat, lon, radius = 50) {
const queryKey = `${lat},${lon},${radius}`;
// Return cached results if the query is identical to the last one
if (this.lastQueryKey === queryKey && this.cachedResults) {
console.log('Returning cached Overpass results for:', queryKey);
return this.cachedResults;
}
// Cancel previous request if it exists
if (this.controller) {
this.controller.abort();
@ -33,7 +43,13 @@ out center;
const data = await res.json();
// Normalize data
return data.elements.map(this.normalizePoi);
const results = data.elements.map(this.normalizePoi);
// Update cache
this.lastQueryKey = queryKey;
this.cachedResults = results;
return results;
} catch (e) {
if (e.name === 'AbortError') {
console.log('Overpass request aborted');

View File

@ -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;
}

View File

@ -1,26 +1,28 @@
import Component from '@glimmer/component';
import { pageTitle } from 'ember-page-title';
import Map from '#components/map';
import PlacesSidebar from '#components/places-sidebar';
import AppHeader from '#components/app-header';
import SettingsPane from '#components/settings-pane';
import { service } from '@ember/service';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';
import { eq } from 'ember-truth-helpers';
import { and, or } from 'ember-truth-helpers';
import { or } from 'ember-truth-helpers';
import { on } from '@ember/modifier';
export default class ApplicationComponent extends Component {
@service storage;
@service mapUi;
@service router;
@tracked nearbyPlaces = null;
@tracked isSettingsOpen = false;
// @tracked bookmarksVersion = 0; // Moved to storage service
get isSidebarOpen() {
return !!this.nearbyPlaces || this.router.currentRouteName === 'place';
// We consider the sidebar "open" if we are in search or place routes.
// This helps the map know if it should shift the center or adjust view.
return (
this.router.currentRouteName === 'place' ||
this.router.currentRouteName === 'search'
);
}
constructor() {
@ -30,32 +32,6 @@ export default class ApplicationComponent extends Component {
this.storage;
}
@action
showPlaces(places, selectedPlace = null) {
// Helper to resolve a place to its bookmark if it exists
const resolvePlace = (p) => {
if (!p) return null;
// We use the OSM ID to check if we already have this place saved
const saved = this.storage.findPlaceById(p.osmId);
return saved || p;
};
const resolvedSelected = resolvePlace(selectedPlace);
const resolvedPlaces = places ? places.map(resolvePlace) : [];
// If we have a specific place, transition to the route
if (resolvedSelected) {
// Pass the FULL object model to avoid re-fetching!
// The Route's serialize() hook handles URL generation.
this.router.transitionTo('place', resolvedSelected);
this.nearbyPlaces = null; // Clear list when selecting specific
} else if (resolvedPlaces && resolvedPlaces.length > 0) {
// Show list case
this.nearbyPlaces = resolvedPlaces;
this.router.transitionTo('index');
}
}
@action
toggleSettings() {
this.isSettingsOpen = !this.isSettingsOpen;
@ -66,29 +42,20 @@ export default class ApplicationComponent extends Component {
this.isSettingsOpen = false;
}
@action
selectFromList(place) {
if (place) {
// Optimize: Pass full object to avoid fetch
this.router.transitionTo('place', place);
}
}
@action
handleOutsideClick() {
if (this.isSettingsOpen) {
this.closeSettings();
} else {
this.closeSidebar();
} else if (this.router.currentRouteName === 'search') {
this.router.transitionTo('index');
} else if (this.router.currentRouteName === 'place') {
// If in place route, decide if we want to go back to search or index
// For now, let's go to index or maybe back to search if search params exist?
// Simplest behavior: clear selection
this.router.transitionTo('index');
}
}
@action
closeSidebar() {
this.nearbyPlaces = null;
this.router.transitionTo('index');
}
@action
refreshBookmarks() {
this.storage.notifyChange();
@ -113,19 +80,10 @@ export default class ApplicationComponent extends Component {
{{/if}}
<Map
@onPlacesFound={{this.showPlaces}}
@isSidebarOpen={{or this.isSidebarOpen this.isSettingsOpen}}
@onOutsideClick={{this.handleOutsideClick}}
/>
{{#if (and (eq this.router.currentRouteName "index") this.nearbyPlaces)}}
<PlacesSidebar
@places={{this.nearbyPlaces}}
@onSelect={{this.selectFromList}}
@onClose={{this.closeSidebar}}
/>
{{/if}}
{{#if this.isSettingsOpen}}
<SettingsPane @onClose={{this.closeSettings}} />
{{/if}}

View File

@ -7,6 +7,7 @@ import { tracked } from '@glimmer/tracking';
export default class PlaceTemplate extends Component {
@service router;
@service storage;
@service mapUi;
@tracked localPlace = null;
@ -72,8 +73,26 @@ export default class PlaceTemplate extends Component {
this.storage.notifyChange();
}
@action
navigateBack(place) {
// The sidebar calls this with null when "Back" is clicked.
if (place === null) {
// If we have history, go back (preserves search state)
if (window.history.length > 1) {
window.history.back();
} else {
// Fallback if opened directly
this.router.transitionTo('index');
}
} else {
// If a place is selected (unlikely in this view, but possible if we add related links)
this.router.transitionTo('place', place);
}
}
@action
close() {
// Clear search results so we don't fall back to the list
this.router.transitionTo('index');
}
@ -81,6 +100,7 @@ export default class PlaceTemplate extends Component {
<PlacesSidebar
@selectedPlace={{this.place}}
@onClose={{this.close}}
@onSelect={{this.navigateBack}}
@onBookmarkChange={{this.refreshMap}}
@onUpdate={{this.handleUpdate}}
/>

28
app/templates/search.gjs Normal file
View 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
View 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())
);
}

View File

@ -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">

View File

@ -1,6 +1,6 @@
{
"name": "marco",
"version": "1.8.10",
"version": "1.9.0",
"private": true,
"description": "Unhosted maps app",
"repository": {

View File

@ -6,7 +6,7 @@
"scope": "/",
"display": "standalone",
"background_color": "#f8f9fa",
"theme_color": "#333333",
"theme_color": "#2a3743",
"icons": [
{
"src": "/icons/icon-192.png",

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -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-DwYp7tls.js"></script>
<link rel="stylesheet" crossorigin href="/assets/main-BZSIy5va.css">
</head>
<body>
</body>

View File

@ -6,7 +6,7 @@
"scope": "/",
"display": "standalone",
"background_color": "#f8f9fa",
"theme_color": "#333333",
"theme_color": "#2a3743",
"icons": [
{
"src": "/icons/icon-192.png",