Compare commits
33 Commits
43b2700465
...
v1.12.2
| Author | SHA1 | Date | |
|---|---|---|---|
|
ee5e56910d
|
|||
|
e019fc2d6b
|
|||
|
9e03426b2e
|
|||
|
ecbf77c573
|
|||
|
703a5e8de0
|
|||
|
b3c733769c
|
|||
|
60b2548efd
|
|||
|
2e632658ad
|
|||
|
845be96b71
|
|||
|
9ac4273fae
|
|||
|
3a825c3d6c
|
|||
|
a6ca362876
|
|||
|
95e9c621a5
|
|||
|
e980431c17
|
|||
|
4fdf2e2fb6
|
|||
|
de1b162ee9
|
|||
|
1df77c2045
|
|||
|
eb1445b749
|
|||
|
316a38dbf8
|
|||
|
7bcb572dbf
|
|||
|
d827fe263b
|
|||
|
1926e2b20c
|
|||
|
df1f32d8bd
|
|||
|
aa058bd7a3
|
|||
|
361a826e4f
|
|||
|
ff01d54fdd
|
|||
|
f73677139d
|
|||
|
8135695bba
|
|||
|
8217e85836
|
|||
|
d9645d1a8c
|
|||
|
688e8eda8d
|
|||
|
323aab8256
|
|||
|
ecb3fe4b5a
|
@@ -18,15 +18,15 @@ jobs:
|
|||||||
timeout-minutes: 10
|
timeout-minutes: 10
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v4
|
||||||
- uses: pnpm/action-setup@v4
|
- uses: pnpm/action-setup@v4
|
||||||
with:
|
with:
|
||||||
version: 10
|
version: 10
|
||||||
- name: Install Node
|
- name: Install Node
|
||||||
uses: actions/setup-node@v6
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 22
|
||||||
cache: pnpm
|
|
||||||
- name: Install Dependencies
|
- name: Install Dependencies
|
||||||
run: pnpm install --frozen-lockfile
|
run: pnpm install --frozen-lockfile
|
||||||
- name: Lint
|
- name: Lint
|
||||||
@@ -35,18 +35,16 @@ jobs:
|
|||||||
test:
|
test:
|
||||||
name: "Test"
|
name: "Test"
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
container:
|
||||||
|
image: cypress/browsers:node-22.19.0-chrome-139.0.7258.154-1-ff-142.0.1-edge-139.0.3405.125-1
|
||||||
timeout-minutes: 10
|
timeout-minutes: 10
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v4
|
||||||
- uses: pnpm/action-setup@v4
|
- uses: pnpm/action-setup@v4
|
||||||
with:
|
with:
|
||||||
version: 10
|
version: 10
|
||||||
- name: Install Node
|
|
||||||
uses: actions/setup-node@v6
|
|
||||||
with:
|
|
||||||
node-version: 22
|
|
||||||
cache: pnpm
|
|
||||||
- name: Install Dependencies
|
- name: Install Dependencies
|
||||||
run: pnpm install --frozen-lockfile
|
run: pnpm install --frozen-lockfile
|
||||||
- name: Run Tests
|
- name: Run Tests
|
||||||
@@ -1,3 +1,7 @@
|
|||||||
export default {
|
export default {
|
||||||
extends: ['stylelint-config-standard'],
|
extends: ['stylelint-config-standard'],
|
||||||
|
rules: {
|
||||||
|
'no-descending-specificity': null,
|
||||||
|
'property-no-vendor-prefix': null,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Project Status: Marco
|
# Project Status: Marco
|
||||||
|
|
||||||
**Last Updated:** Tue Jan 27 2026
|
**Last Updated:** Tue Feb 24 2026
|
||||||
|
|
||||||
## Project Context
|
## Project Context
|
||||||
|
|
||||||
@@ -36,6 +36,12 @@ We are building **Marco**, a decentralized maps application using **Ember.js** (
|
|||||||
- **Persistence:** Saves and restores map center and zoom level using `localStorage` (key: `marco:map-view`).
|
- **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.
|
- **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`).
|
- **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.
|
||||||
|
|
||||||
### 2. RemoteStorage Module (`@remotestorage/module-places`)
|
### 2. RemoteStorage Module (`@remotestorage/module-places`)
|
||||||
|
|
||||||
@@ -75,6 +81,8 @@ We are building **Marco**, a decentralized maps application using **Ember.js** (
|
|||||||
- `app/utils/geohash-coverage.js`: Logic to calculate required 4-char geohash prefixes for a given bounding box.
|
- `app/utils/geohash-coverage.js`: Logic to calculate required 4-char geohash prefixes for a given bounding box.
|
||||||
- **Format Utils:**
|
- **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".
|
- `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:**
|
- **Build & DevOps:**
|
||||||
- **Icon Generation:** Added `build:icons` script using `magick` and `rsvg-convert` to automate PNG generation from SVG.
|
- **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`.
|
- **Dependencies:** Documented system requirements (ImageMagick, librsvg) in `README.md`.
|
||||||
@@ -103,6 +111,16 @@ We are building **Marco**, a decentralized maps application using **Ember.js** (
|
|||||||
- Responsive crosshair sizing (48px desktop / 24px mobile).
|
- Responsive crosshair sizing (48px desktop / 24px mobile).
|
||||||
- **Persistence:** Form data (Title, Description) and Map coordinates are securely saved to RemoteStorage via `storage.storePlace`.
|
- **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.
|
||||||
|
|
||||||
## Current State
|
## Current State
|
||||||
|
|
||||||
- **Repo:** The app runs via `pnpm start`.
|
- **Repo:** The app runs via `pnpm start`.
|
||||||
@@ -120,9 +138,10 @@ We are building **Marco**, a decentralized maps application using **Ember.js** (
|
|||||||
|
|
||||||
## Files Currently in Focus
|
## Files Currently in Focus
|
||||||
|
|
||||||
|
- `app/services/osm.js`
|
||||||
- `app/components/map.gjs`
|
- `app/components/map.gjs`
|
||||||
- `app/components/place-edit-form.gjs`
|
- `app/routes/place.js`
|
||||||
- `app/templates/place/new.gjs`
|
- `app/utils/osm.js`
|
||||||
|
|
||||||
## Next Steps & Pending Tasks
|
## Next Steps & Pending Tasks
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import target from 'feather-icons/dist/icons/target.svg?raw';
|
|||||||
import user from 'feather-icons/dist/icons/user.svg?raw';
|
import user from 'feather-icons/dist/icons/user.svg?raw';
|
||||||
import x from 'feather-icons/dist/icons/x.svg?raw';
|
import x from 'feather-icons/dist/icons/x.svg?raw';
|
||||||
import zap from 'feather-icons/dist/icons/zap.svg?raw';
|
import zap from 'feather-icons/dist/icons/zap.svg?raw';
|
||||||
|
import wikipedia from '../icons/wikipedia.svg?raw';
|
||||||
|
|
||||||
const ICONS = {
|
const ICONS = {
|
||||||
'arrow-left': arrowLeft,
|
'arrow-left': arrowLeft,
|
||||||
@@ -45,6 +46,7 @@ const ICONS = {
|
|||||||
settings,
|
settings,
|
||||||
target,
|
target,
|
||||||
user,
|
user,
|
||||||
|
wikipedia,
|
||||||
x,
|
x,
|
||||||
zap,
|
zap,
|
||||||
};
|
};
|
||||||
@@ -63,7 +65,9 @@ export default class IconComponent extends Component {
|
|||||||
}
|
}
|
||||||
|
|
||||||
get style() {
|
get style() {
|
||||||
return `width:${this.size}px;height:${this.size}px;color:${this.color}`;
|
return htmlSafe(
|
||||||
|
`width:${this.size}px;height:${this.size}px;color:${this.color}`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
get title() {
|
get title() {
|
||||||
@@ -72,7 +76,11 @@ export default class IconComponent extends Component {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
{{#if this.svg}}
|
{{#if this.svg}}
|
||||||
<span class="icon" style={{this.style}} title={{this.title}}>
|
<span
|
||||||
|
class="icon {{if @filled 'icon-filled'}}"
|
||||||
|
style={{this.style}}
|
||||||
|
title={{this.title}}
|
||||||
|
>
|
||||||
{{htmlSafe this.svg}}
|
{{htmlSafe this.svg}}
|
||||||
</span>
|
</span>
|
||||||
{{/if}}
|
{{/if}}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import LayerGroup from 'ol/layer/Group.js';
|
|||||||
import VectorLayer from 'ol/layer/Vector.js';
|
import VectorLayer from 'ol/layer/Vector.js';
|
||||||
import VectorSource from 'ol/source/Vector.js';
|
import VectorSource from 'ol/source/Vector.js';
|
||||||
import Feature from 'ol/Feature.js';
|
import Feature from 'ol/Feature.js';
|
||||||
|
import GeoJSON from 'ol/format/GeoJSON.js';
|
||||||
import Point from 'ol/geom/Point.js';
|
import Point from 'ol/geom/Point.js';
|
||||||
import Geolocation from 'ol/Geolocation.js';
|
import Geolocation from 'ol/Geolocation.js';
|
||||||
import { Style, Circle, Fill, Stroke } from 'ol/style.js';
|
import { Style, Circle, Fill, Stroke } from 'ol/style.js';
|
||||||
@@ -27,6 +28,7 @@ export default class MapComponent extends Component {
|
|||||||
|
|
||||||
mapInstance;
|
mapInstance;
|
||||||
bookmarkSource;
|
bookmarkSource;
|
||||||
|
selectedShapeSource;
|
||||||
searchOverlay;
|
searchOverlay;
|
||||||
searchOverlayElement;
|
searchOverlayElement;
|
||||||
selectedPinOverlay;
|
selectedPinOverlay;
|
||||||
@@ -40,6 +42,22 @@ export default class MapComponent extends Component {
|
|||||||
|
|
||||||
const openfreemap = new LayerGroup();
|
const openfreemap = new LayerGroup();
|
||||||
|
|
||||||
|
// Create a vector source and layer for the selected shape (outline)
|
||||||
|
this.selectedShapeSource = new VectorSource();
|
||||||
|
const selectedShapeLayer = new VectorLayer({
|
||||||
|
source: this.selectedShapeSource,
|
||||||
|
style: new Style({
|
||||||
|
stroke: new Stroke({
|
||||||
|
color: '#3388ff',
|
||||||
|
width: 4,
|
||||||
|
}),
|
||||||
|
fill: new Fill({
|
||||||
|
color: 'rgba(51, 136, 255, 0.1)',
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
zIndex: 5, // Below bookmarks (10) but above tiles
|
||||||
|
});
|
||||||
|
|
||||||
// Create a vector source and layer for bookmarks
|
// Create a vector source and layer for bookmarks
|
||||||
this.bookmarkSource = new VectorSource();
|
this.bookmarkSource = new VectorSource();
|
||||||
const bookmarkLayer = new VectorLayer({
|
const bookmarkLayer = new VectorLayer({
|
||||||
@@ -99,7 +117,7 @@ export default class MapComponent extends Component {
|
|||||||
|
|
||||||
this.mapInstance = new Map({
|
this.mapInstance = new Map({
|
||||||
target: element,
|
target: element,
|
||||||
layers: [openfreemap, bookmarkLayer],
|
layers: [openfreemap, selectedShapeLayer, bookmarkLayer],
|
||||||
view: view,
|
view: view,
|
||||||
controls: defaultControls({
|
controls: defaultControls({
|
||||||
zoom: true,
|
zoom: true,
|
||||||
@@ -426,6 +444,11 @@ export default class MapComponent extends Component {
|
|||||||
|
|
||||||
if (!this.selectedPinOverlay || !this.selectedPinElement) return;
|
if (!this.selectedPinOverlay || !this.selectedPinElement) return;
|
||||||
|
|
||||||
|
// Clear any previous shape
|
||||||
|
if (this.selectedShapeSource) {
|
||||||
|
this.selectedShapeSource.clear();
|
||||||
|
}
|
||||||
|
|
||||||
if (selected && selected.lat && selected.lon) {
|
if (selected && selected.lat && selected.lon) {
|
||||||
const coords = fromLonLat([selected.lon, selected.lat]);
|
const coords = fromLonLat([selected.lon, selected.lat]);
|
||||||
this.selectedPinOverlay.setPosition(coords);
|
this.selectedPinOverlay.setPosition(coords);
|
||||||
@@ -436,7 +459,23 @@ export default class MapComponent extends Component {
|
|||||||
void this.selectedPinElement.offsetWidth;
|
void this.selectedPinElement.offsetWidth;
|
||||||
this.selectedPinElement.classList.add('active');
|
this.selectedPinElement.classList.add('active');
|
||||||
|
|
||||||
this.handlePinVisibility(coords);
|
// Draw GeoJSON shape if available
|
||||||
|
if (selected.geojson && this.selectedShapeSource) {
|
||||||
|
try {
|
||||||
|
const feature = new GeoJSON().readFeature(selected.geojson, {
|
||||||
|
featureProjection: 'EPSG:3857',
|
||||||
|
});
|
||||||
|
this.selectedShapeSource.addFeature(feature);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Failed to render selected place shape:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selected.bbox) {
|
||||||
|
this.zoomToBbox(selected.bbox);
|
||||||
|
} else {
|
||||||
|
this.handlePinVisibility(coords);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
this.selectedPinElement.classList.remove('active');
|
this.selectedPinElement.classList.remove('active');
|
||||||
// Hide it effectively by moving it away or just relying on display:none in CSS
|
// Hide it effectively by moving it away or just relying on display:none in CSS
|
||||||
@@ -444,6 +483,55 @@ export default class MapComponent extends Component {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
zoomToBbox(bbox) {
|
||||||
|
if (!this.mapInstance || !bbox) return;
|
||||||
|
|
||||||
|
const view = this.mapInstance.getView();
|
||||||
|
const size = this.mapInstance.getSize();
|
||||||
|
|
||||||
|
// Convert bbox to extent: [minx, miny, maxx, maxy]
|
||||||
|
const min = fromLonLat([bbox.minLon, bbox.minLat]);
|
||||||
|
const max = fromLonLat([bbox.maxLon, bbox.maxLat]);
|
||||||
|
const extent = [...min, ...max];
|
||||||
|
|
||||||
|
// Default padding for full screen: 15% on all sides (70% visible)
|
||||||
|
let padding = [
|
||||||
|
size[1] * 0.15, // Top
|
||||||
|
size[0] * 0.15, // Right
|
||||||
|
size[1] * 0.15, // Bottom
|
||||||
|
size[0] * 0.15, // Left
|
||||||
|
];
|
||||||
|
|
||||||
|
// Mobile: Bottom sheet covers 50% of the screen height
|
||||||
|
if (size[0] <= 768) {
|
||||||
|
// We want the geometry to be centered in the top 50% of the screen.
|
||||||
|
// Top padding: 15% of the VISIBLE height (size[1] * 0.5)
|
||||||
|
const visibleHeight = size[1] * 0.5;
|
||||||
|
const topPadding = visibleHeight * 0.15;
|
||||||
|
const bottomPadding = size[1] * 0.5 + visibleHeight * 0.15; // Sheet + padding
|
||||||
|
|
||||||
|
padding[0] = topPadding;
|
||||||
|
padding[2] = bottomPadding;
|
||||||
|
}
|
||||||
|
// Desktop: Sidebar covers left side (approx 400px)
|
||||||
|
else if (this.args.isSidebarOpen) {
|
||||||
|
const sidebarWidth = 400;
|
||||||
|
const visibleWidth = size[0] - sidebarWidth;
|
||||||
|
|
||||||
|
// Left padding: Sidebar + 15% of visible width
|
||||||
|
padding[3] = sidebarWidth + visibleWidth * 0.15;
|
||||||
|
// Right padding: 15% of visible width
|
||||||
|
padding[1] = visibleWidth * 0.15;
|
||||||
|
}
|
||||||
|
|
||||||
|
view.fit(extent, {
|
||||||
|
padding: padding,
|
||||||
|
duration: 1000,
|
||||||
|
easing: (t) => t * (2 - t),
|
||||||
|
maxZoom: 19,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
handlePinVisibility(coords) {
|
handlePinVisibility(coords) {
|
||||||
if (!this.mapInstance) return;
|
if (!this.mapInstance) return;
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import Component from '@glimmer/component';
|
import Component from '@glimmer/component';
|
||||||
import { fn } from '@ember/helper';
|
import { fn } from '@ember/helper';
|
||||||
import { on } from '@ember/modifier';
|
import { on } from '@ember/modifier';
|
||||||
|
import { htmlSafe } from '@ember/template';
|
||||||
import { humanizeOsmTag } from '../utils/format-text';
|
import { humanizeOsmTag } from '../utils/format-text';
|
||||||
import { getLocalizedName } from '../utils/osm';
|
import { getLocalizedName, getPlaceType } from '../utils/osm';
|
||||||
import Icon from '../components/icon';
|
import Icon from '../components/icon';
|
||||||
import PlaceEditForm from './place-edit-form';
|
import PlaceEditForm from './place-edit-form';
|
||||||
|
|
||||||
@@ -47,58 +48,107 @@ export default class PlaceDetails extends Component {
|
|||||||
}
|
}
|
||||||
|
|
||||||
get type() {
|
get type() {
|
||||||
const rawType =
|
return getPlaceType(this.tags);
|
||||||
this.tags.amenity ||
|
|
||||||
this.tags.shop ||
|
|
||||||
this.tags.tourism ||
|
|
||||||
this.tags.leisure ||
|
|
||||||
this.tags.historic ||
|
|
||||||
'Point of Interest';
|
|
||||||
|
|
||||||
return humanizeOsmTag(rawType);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
get address() {
|
get address() {
|
||||||
const t = this.tags;
|
const t = this.tags;
|
||||||
const parts = [];
|
const parts = [];
|
||||||
|
|
||||||
|
// Helper to get value from multiple keys
|
||||||
|
const get = (...keys) => {
|
||||||
|
for (const k of keys) {
|
||||||
|
if (t[k]) return t[k];
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
// Street + Number
|
// Street + Number
|
||||||
if (t['addr:street']) {
|
let street = get('addr:street', 'street');
|
||||||
let street = t['addr:street'];
|
const number = get('addr:housenumber', 'housenumber');
|
||||||
if (t['addr:housenumber']) {
|
|
||||||
street += ` ${t['addr:housenumber']}`;
|
if (street) {
|
||||||
|
if (number) {
|
||||||
|
street = `${street} ${number}`;
|
||||||
}
|
}
|
||||||
parts.push(street);
|
parts.push(street);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Postcode + City
|
// Postcode + City
|
||||||
if (t['addr:city']) {
|
let city = get('addr:city', 'city');
|
||||||
let city = t['addr:city'];
|
const postcode = get('addr:postcode', 'postcode');
|
||||||
if (t['addr:postcode']) {
|
|
||||||
city = `${t['addr:postcode']} ${city}`;
|
if (city) {
|
||||||
|
if (postcode) {
|
||||||
|
city = `${postcode} ${city}`;
|
||||||
}
|
}
|
||||||
parts.push(city);
|
parts.push(city);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// State + Country (if not already covered)
|
||||||
|
const state = get('addr:state', 'state');
|
||||||
|
const country = get('addr:country', 'country');
|
||||||
|
|
||||||
|
if (state && state !== city) parts.push(state);
|
||||||
|
if (country) parts.push(country);
|
||||||
|
|
||||||
if (parts.length === 0) return null;
|
if (parts.length === 0) return null;
|
||||||
return parts.join(', ');
|
return parts.join(', ');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
formatMultiLine(val, type) {
|
||||||
|
if (!val) return null;
|
||||||
|
const parts = val
|
||||||
|
.split(';')
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
if (parts.length === 0) return null;
|
||||||
|
|
||||||
|
if (type === 'phone') {
|
||||||
|
return htmlSafe(
|
||||||
|
parts.map((p) => `<a href="tel:${p}">${p}</a>`).join('<br>')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === 'url') {
|
||||||
|
return htmlSafe(
|
||||||
|
parts
|
||||||
|
.map(
|
||||||
|
(url) =>
|
||||||
|
`<a href="${url}" target="_blank" rel="noopener noreferrer">${this.getDomain(
|
||||||
|
url
|
||||||
|
)}</a>`
|
||||||
|
)
|
||||||
|
.join('<br>')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return htmlSafe(parts.join('<br>'));
|
||||||
|
}
|
||||||
|
|
||||||
get phone() {
|
get phone() {
|
||||||
return this.tags.phone || this.tags['contact:phone'];
|
const val = this.tags.phone || this.tags['contact:phone'];
|
||||||
|
return this.formatMultiLine(val, 'phone');
|
||||||
}
|
}
|
||||||
|
|
||||||
get website() {
|
get website() {
|
||||||
return this.place.url || this.tags.website || this.tags['contact:website'];
|
const val =
|
||||||
|
this.place.url || this.tags.website || this.tags['contact:website'];
|
||||||
|
return this.formatMultiLine(val, 'url');
|
||||||
}
|
}
|
||||||
|
|
||||||
get websiteDomain() {
|
getDomain(urlStr) {
|
||||||
const url = new URL(this.website);
|
try {
|
||||||
return url.hostname;
|
const url = new URL(urlStr);
|
||||||
|
return url.hostname;
|
||||||
|
} catch {
|
||||||
|
return urlStr;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
get openingHours() {
|
get openingHours() {
|
||||||
return this.tags.opening_hours;
|
const val = this.tags.opening_hours;
|
||||||
|
return this.formatMultiLine(val);
|
||||||
}
|
}
|
||||||
|
|
||||||
get cuisine() {
|
get cuisine() {
|
||||||
@@ -110,7 +160,12 @@ export default class PlaceDetails extends Component {
|
|||||||
}
|
}
|
||||||
|
|
||||||
get wikipedia() {
|
get wikipedia() {
|
||||||
return this.tags.wikipedia;
|
const val = this.tags.wikipedia;
|
||||||
|
if (!val) return null;
|
||||||
|
return val
|
||||||
|
.split(';')
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter(Boolean)[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
get geoLink() {
|
get geoLink() {
|
||||||
@@ -141,6 +196,16 @@ export default class PlaceDetails extends Component {
|
|||||||
return `https://www.google.com/maps/search/?api=1&query=${this.name}&query=${this.place.lat},${this.place.lon}`;
|
return `https://www.google.com/maps/search/?api=1&query=${this.name}&query=${this.place.lat},${this.place.lon}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get showDescription() {
|
||||||
|
// If it's a Photon result, the description IS the address.
|
||||||
|
// Since we are showing the address in the meta section (bottom),
|
||||||
|
// we should hide the description to avoid duplication.
|
||||||
|
if (this.place.source === 'photon') return false;
|
||||||
|
|
||||||
|
// Otherwise (e.g. saved place with custom description), show it.
|
||||||
|
return !!this.place.description;
|
||||||
|
}
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="place-details">
|
<div class="place-details">
|
||||||
{{#if this.isEditing}}
|
{{#if this.isEditing}}
|
||||||
@@ -154,7 +219,7 @@ export default class PlaceDetails extends Component {
|
|||||||
<p class="place-type">
|
<p class="place-type">
|
||||||
{{this.type}}
|
{{this.type}}
|
||||||
</p>
|
</p>
|
||||||
{{#if this.place.description}}
|
{{#if this.showDescription}}
|
||||||
<p class="place-description">
|
<p class="place-description">
|
||||||
{{this.place.description}}
|
{{this.place.description}}
|
||||||
</p>
|
</p>
|
||||||
@@ -194,7 +259,7 @@ export default class PlaceDetails extends Component {
|
|||||||
<div class="meta-info">
|
<div class="meta-info">
|
||||||
|
|
||||||
{{#if this.cuisine}}
|
{{#if this.cuisine}}
|
||||||
<p>
|
<p class="cuisine-info">
|
||||||
<strong>Cuisine:</strong>
|
<strong>Cuisine:</strong>
|
||||||
{{this.cuisine}}
|
{{this.cuisine}}
|
||||||
</p>
|
</p>
|
||||||
@@ -203,36 +268,42 @@ export default class PlaceDetails extends Component {
|
|||||||
{{#if this.openingHours}}
|
{{#if this.openingHours}}
|
||||||
<p class="content-with-icon">
|
<p class="content-with-icon">
|
||||||
<Icon @name="clock" @title="Opening hours" />
|
<Icon @name="clock" @title="Opening hours" />
|
||||||
<span>{{this.openingHours}}</span>
|
<span>
|
||||||
|
{{this.openingHours}}
|
||||||
|
</span>
|
||||||
</p>
|
</p>
|
||||||
{{/if}}
|
{{/if}}
|
||||||
|
|
||||||
{{#if this.phone}}
|
{{#if this.phone}}
|
||||||
<p class="content-with-icon">
|
<p class="content-with-icon">
|
||||||
<Icon @name="phone" @title="Phone" />
|
<Icon @name="phone" @title="Phone" />
|
||||||
<span><a href="tel:{{this.phone}}">{{this.phone}}</a></span>
|
<span>
|
||||||
|
{{this.phone}}
|
||||||
|
</span>
|
||||||
</p>
|
</p>
|
||||||
{{/if}}
|
{{/if}}
|
||||||
|
|
||||||
{{#if this.website}}
|
{{#if this.website}}
|
||||||
<p class="content-with-icon">
|
<p class="content-with-icon">
|
||||||
<Icon @name="globe" @title="Website" />
|
<Icon @name="globe" @title="Website" />
|
||||||
<span><a
|
<span>
|
||||||
href={{this.website}}
|
{{this.website}}
|
||||||
target="_blank"
|
</span>
|
||||||
rel="noopener noreferrer"
|
|
||||||
>{{this.websiteDomain}}</a></span>
|
|
||||||
</p>
|
</p>
|
||||||
{{/if}}
|
{{/if}}
|
||||||
|
|
||||||
{{#if this.wikipedia}}
|
{{#if this.wikipedia}}
|
||||||
<p>
|
<p class="content-with-icon">
|
||||||
<strong>Wikipedia:</strong>
|
<Icon @name="wikipedia" @title="Wikipedia" @filled={{true}} />
|
||||||
<a
|
<span>
|
||||||
href="https://wikipedia.org/wiki/{{this.wikipedia}}"
|
<a
|
||||||
target="_blank"
|
href="https://wikipedia.org/wiki/{{this.wikipedia}}"
|
||||||
rel="noopener noreferrer"
|
target="_blank"
|
||||||
>Article</a>
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
Wikipedia
|
||||||
|
</a>
|
||||||
|
</span>
|
||||||
</p>
|
</p>
|
||||||
{{/if}}
|
{{/if}}
|
||||||
|
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ export default class PlaceEditForm extends Component {
|
|||||||
<form class="edit-form" {{on "submit" this.handleSubmit}}>
|
<form class="edit-form" {{on "submit" this.handleSubmit}}>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="edit-title">Title</label>
|
<label for="edit-title">Title</label>
|
||||||
|
{{! template-lint-disable no-autofocus-attribute }}
|
||||||
<input
|
<input
|
||||||
id="edit-title"
|
id="edit-title"
|
||||||
type="text"
|
type="text"
|
||||||
|
|||||||
@@ -4,10 +4,11 @@ import { action } from '@ember/object';
|
|||||||
import { on } from '@ember/modifier';
|
import { on } from '@ember/modifier';
|
||||||
import { fn } from '@ember/helper';
|
import { fn } from '@ember/helper';
|
||||||
import or from 'ember-truth-helpers/helpers/or';
|
import or from 'ember-truth-helpers/helpers/or';
|
||||||
|
import eq from 'ember-truth-helpers/helpers/eq';
|
||||||
import PlaceDetails from './place-details';
|
import PlaceDetails from './place-details';
|
||||||
import Icon from './icon';
|
import Icon from './icon';
|
||||||
import humanizeOsmTag from '../helpers/humanize-osm-tag';
|
import humanizeOsmTag from '../helpers/humanize-osm-tag';
|
||||||
import { getLocalizedName } from '../utils/osm';
|
import { getLocalizedName, getPlaceType } from '../utils/osm';
|
||||||
|
|
||||||
export default class PlacesSidebar extends Component {
|
export default class PlacesSidebar extends Component {
|
||||||
@service storage;
|
@service storage;
|
||||||
@@ -144,6 +145,11 @@ export default class PlacesSidebar extends Component {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get isNearbySearch() {
|
||||||
|
const qp = this.router.currentRoute.queryParams;
|
||||||
|
return !qp.q && qp.lat && qp.lon;
|
||||||
|
}
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="sidebar">
|
<div class="sidebar">
|
||||||
<div class="sidebar-header">
|
<div class="sidebar-header">
|
||||||
@@ -154,7 +160,12 @@ export default class PlacesSidebar extends Component {
|
|||||||
{{on "click" this.clearSelection}}
|
{{on "click" this.clearSelection}}
|
||||||
><Icon @name="arrow-left" @size={{20}} @color="#333" /></button>
|
><Icon @name="arrow-left" @size={{20}} @color="#333" /></button>
|
||||||
{{else}}
|
{{else}}
|
||||||
<h2><Icon @name="target" @size={{20}} @color="#ea4335" /> Nearby</h2>
|
{{#if this.isNearbySearch}}
|
||||||
|
<h2><Icon @name="target" @size={{20}} @color="#ea4335" />
|
||||||
|
Nearby</h2>
|
||||||
|
{{else}}
|
||||||
|
<h2><Icon @name="search" @size={{20}} @color="#333" /> Results</h2>
|
||||||
|
{{/if}}
|
||||||
{{/if}}
|
{{/if}}
|
||||||
<button type="button" class="close-btn" {{on "click" @onClose}}><Icon
|
<button type="button" class="close-btn" {{on "click" @onClose}}><Icon
|
||||||
@name="x"
|
@name="x"
|
||||||
@@ -186,22 +197,29 @@ export default class PlacesSidebar extends Component {
|
|||||||
place.osmTags.name:en
|
place.osmTags.name:en
|
||||||
"Unnamed Place"
|
"Unnamed Place"
|
||||||
}}</div>
|
}}</div>
|
||||||
<div class="place-type">{{humanizeOsmTag
|
<div class="place-type">
|
||||||
(or
|
{{#if (eq place.source "osm")}}
|
||||||
place.osmTags.amenity
|
{{humanizeOsmTag place.type}}
|
||||||
place.osmTags.shop
|
{{else if (eq place.source "photon")}}
|
||||||
place.osmTags.tourism
|
{{place.description}}
|
||||||
place.osmTags.leisure
|
{{else}}
|
||||||
place.osmTags.historic
|
{{#if place.osmTags}}
|
||||||
"Point of Interest"
|
{{humanizeOsmTag (getPlaceType place.osmTags)}}
|
||||||
)
|
{{else if place.description}}
|
||||||
}}</div>
|
{{place.description}}
|
||||||
|
{{/if}}
|
||||||
|
{{/if}}
|
||||||
|
</div>
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
{{/each}}
|
{{/each}}
|
||||||
</ul>
|
</ul>
|
||||||
{{else}}
|
{{else}}
|
||||||
<p class="empty-state">No places found nearby.</p>
|
{{#if this.isNearbySearch}}
|
||||||
|
<p class="empty-state">No places found nearby.</p>
|
||||||
|
{{else}}
|
||||||
|
<p class="empty-state">No results found.</p>
|
||||||
|
{{/if}}
|
||||||
{{/if}}
|
{{/if}}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import { on } from '@ember/modifier';
|
|||||||
import { fn } from '@ember/helper';
|
import { fn } from '@ember/helper';
|
||||||
import { task, timeout } from 'ember-concurrency';
|
import { task, timeout } from 'ember-concurrency';
|
||||||
import Icon from '#components/icon';
|
import Icon from '#components/icon';
|
||||||
|
import humanizeOsmTag from '../helpers/humanize-osm-tag';
|
||||||
|
import eq from 'ember-truth-helpers/helpers/eq';
|
||||||
|
|
||||||
export default class SearchBoxComponent extends Component {
|
export default class SearchBoxComponent extends Component {
|
||||||
@service photon;
|
@service photon;
|
||||||
@@ -132,7 +134,7 @@ export default class SearchBoxComponent extends Component {
|
|||||||
aria-label="Menu"
|
aria-label="Menu"
|
||||||
{{on "click" @onToggleMenu}}
|
{{on "click" @onToggleMenu}}
|
||||||
>
|
>
|
||||||
<Icon @name="menu" @size={{24}} @color="#5f6368" />
|
<Icon @name="menu" @size={{20}} @color="#5f6368" />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<input
|
<input
|
||||||
@@ -158,7 +160,7 @@ export default class SearchBoxComponent extends Component {
|
|||||||
{{on "click" this.clear}}
|
{{on "click" this.clear}}
|
||||||
aria-label="Clear"
|
aria-label="Clear"
|
||||||
>
|
>
|
||||||
<Icon @name="x" @size={{16}} @color="#5f6368" />
|
<Icon @name="x" @size={{20}} @color="#5f6368" />
|
||||||
</button>
|
</button>
|
||||||
{{/if}}
|
{{/if}}
|
||||||
</form>
|
</form>
|
||||||
@@ -178,8 +180,14 @@ export default class SearchBoxComponent extends Component {
|
|||||||
</div>
|
</div>
|
||||||
<div class="result-info">
|
<div class="result-info">
|
||||||
<span class="result-title">{{result.title}}</span>
|
<span class="result-title">{{result.title}}</span>
|
||||||
{{#if result.description}}
|
{{#if (eq result.source "osm")}}
|
||||||
<span class="result-desc">{{result.description}}</span>
|
<span class="result-desc">{{humanizeOsmTag
|
||||||
|
result.type
|
||||||
|
}}</span>
|
||||||
|
{{else}}
|
||||||
|
{{#if result.description}}
|
||||||
|
<span class="result-desc">{{result.description}}</span>
|
||||||
|
{{/if}}
|
||||||
{{/if}}
|
{{/if}}
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { service } from '@ember/service';
|
|||||||
import { action } from '@ember/object';
|
import { action } from '@ember/object';
|
||||||
import Icon from '#components/icon';
|
import Icon from '#components/icon';
|
||||||
import eq from 'ember-truth-helpers/helpers/eq';
|
import eq from 'ember-truth-helpers/helpers/eq';
|
||||||
import not from 'ember-truth-helpers/helpers/not';
|
|
||||||
|
|
||||||
export default class SettingsPane extends Component {
|
export default class SettingsPane extends Component {
|
||||||
@service settings;
|
@service settings;
|
||||||
@@ -22,7 +21,10 @@ export default class SettingsPane extends Component {
|
|||||||
<template>
|
<template>
|
||||||
<div class="sidebar settings-pane">
|
<div class="sidebar settings-pane">
|
||||||
<div class="sidebar-header">
|
<div class="sidebar-header">
|
||||||
<h2>Marco</h2>
|
<h2>
|
||||||
|
<img src="/icons/icon-rounded.svg" alt="" width="32" height="32" />
|
||||||
|
Marco
|
||||||
|
</h2>
|
||||||
<button type="button" class="close-btn" {{on "click" @onClose}}>
|
<button type="button" class="close-btn" {{on "click" @onClose}}>
|
||||||
<Icon @name="x" @size={{20}} @color="#333" />
|
<Icon @name="x" @size={{20}} @color="#333" />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
4
app/icons/wikipedia.svg
Normal file
4
app/icons/wikipedia.svg
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="128" height="128" viewBox="7.15 7.15 113.7 113.7" fill="currentColor">
|
||||||
|
<path d="M 120.85,29.21 C 120.85,29.62 120.72,29.99 120.47,30.33 C 120.21,30.66 119.94,30.83 119.63,30.83 C 117.14,31.07 115.09,31.87 113.51,33.24 C 111.92,34.6 110.29,37.21 108.6,41.05 L 82.8,99.19 C 82.63,99.73 82.16,100 81.38,100 C 80.77,100 80.3,99.73 79.96,99.19 L 65.49,68.93 L 48.85,99.19 C 48.51,99.73 48.04,100 47.43,100 C 46.69,100 46.2,99.73 45.96,99.19 L 20.61,41.05 C 19.03,37.44 17.36,34.92 15.6,33.49 C 13.85,32.06 11.4,31.17 8.27,30.83 C 8,30.83 7.74,30.69 7.51,30.4 C 7.27,30.12 7.15,29.79 7.15,29.42 C 7.15,28.47 7.42,28 7.96,28 C 10.22,28 12.58,28.1 15.05,28.3 C 17.34,28.51 19.5,28.61 21.52,28.61 C 23.58,28.61 26.01,28.51 28.81,28.3 C 31.74,28.1 34.34,28 36.6,28 C 37.14,28 37.41,28.47 37.41,29.42 C 37.41,30.36 37.24,30.83 36.91,30.83 C 34.65,31 32.87,31.58 31.57,32.55 C 30.27,33.53 29.62,34.81 29.62,36.4 C 29.62,37.21 29.89,38.22 30.43,39.43 L 51.38,86.74 L 63.27,64.28 L 52.19,41.05 C 50.2,36.91 48.56,34.23 47.28,33.03 C 46,31.84 44.06,31.1 41.46,30.83 C 41.22,30.83 41,30.69 40.78,30.4 C 40.56,30.12 40.45,29.79 40.45,29.42 C 40.45,28.47 40.68,28 41.16,28 C 43.42,28 45.49,28.1 47.38,28.3 C 49.2,28.51 51.14,28.61 53.2,28.61 C 55.22,28.61 57.36,28.51 59.62,28.3 C 61.95,28.1 64.24,28 66.5,28 C 67.04,28 67.31,28.47 67.31,29.42 C 67.31,30.36 67.15,30.83 66.81,30.83 C 62.29,31.14 60.03,32.42 60.03,34.68 C 60.03,35.69 60.55,37.26 61.6,39.38 L 68.93,54.26 L 76.22,40.65 C 77.23,38.73 77.74,37.11 77.74,35.79 C 77.74,32.69 75.48,31.04 70.96,30.83 C 70.55,30.83 70.35,30.36 70.35,29.42 C 70.35,29.08 70.45,28.76 70.65,28.46 C 70.86,28.15 71.06,28 71.26,28 C 72.88,28 74.87,28.1 77.23,28.3 C 79.49,28.51 81.35,28.61 82.8,28.61 C 83.84,28.61 85.38,28.52 87.4,28.35 C 89.96,28.12 92.11,28 93.83,28 C 94.23,28 94.43,28.4 94.43,29.21 C 94.43,30.29 94.06,30.83 93.32,30.83 C 90.69,31.1 88.57,31.83 86.97,33.01 C 85.37,34.19 83.37,36.87 80.98,41.05 L 71.26,59.02 L 84.42,85.83 L 103.85,40.65 C 104.52,39 104.86,37.48 104.86,36.1 C 104.86,32.79 102.6,31.04 98.08,30.83 C 97.67,30.83 97.47,30.36 97.47,29.42 C 97.47,28.47 97.77,28 98.38,28 C 100.03,28 101.99,28.1 104.25,28.3 C 106.34,28.51 108.1,28.61 109.51,28.61 C 111,28.61 112.72,28.51 114.67,28.3 C 116.7,28.1 118.52,28 120.14,28 C 120.61,28 120.85,28.4 120.85,29.21 z" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.4 KiB |
@@ -48,7 +48,28 @@ export default class PlaceRoute extends Route {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
afterModel(model) {
|
async afterModel(model) {
|
||||||
|
// If the model comes from a search result (e.g. Photon), it might lack detailed geometry.
|
||||||
|
// We want to ensure we have the full OSM object (with polygon/linestring) for display.
|
||||||
|
if (
|
||||||
|
model &&
|
||||||
|
model.osmId &&
|
||||||
|
model.osmType &&
|
||||||
|
model.osmType !== 'node' &&
|
||||||
|
!model.geojson
|
||||||
|
) {
|
||||||
|
// Only fetch if it's NOT a node (nodes don't have interesting geometry anyway, just a point)
|
||||||
|
// Although fetching nodes again ensures we have the latest tags too.
|
||||||
|
console.debug('Model missing geometry, fetching full OSM details...');
|
||||||
|
const fullDetails = await this.loadOsmPlace(model.osmId, model.osmType);
|
||||||
|
|
||||||
|
if (fullDetails) {
|
||||||
|
// Update the model in-place with the fuller details
|
||||||
|
Object.assign(model, fullDetails);
|
||||||
|
console.debug('Enriched model with full OSM details', model);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Notify the Map UI to show the pin
|
// Notify the Map UI to show the pin
|
||||||
if (model) {
|
if (model) {
|
||||||
this.mapUi.selectPlace(model);
|
this.mapUi.selectPlace(model);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import Service, { service } from '@ember/service';
|
import Service, { service } from '@ember/service';
|
||||||
import { getLocalizedName } from '../utils/osm';
|
import { getLocalizedName, getPlaceType } from '../utils/osm';
|
||||||
|
|
||||||
export default class OsmService extends Service {
|
export default class OsmService extends Service {
|
||||||
@service settings;
|
@service settings;
|
||||||
@@ -24,14 +24,30 @@ export default class OsmService extends Service {
|
|||||||
this.controller = new AbortController();
|
this.controller = new AbortController();
|
||||||
const signal = this.controller.signal;
|
const signal = this.controller.signal;
|
||||||
|
|
||||||
|
const typeKeys = [
|
||||||
|
'amenity',
|
||||||
|
'shop',
|
||||||
|
'tourism',
|
||||||
|
'historic',
|
||||||
|
'leisure',
|
||||||
|
'office',
|
||||||
|
'craft',
|
||||||
|
'building',
|
||||||
|
'landuse',
|
||||||
|
'public_transport',
|
||||||
|
'aeroway',
|
||||||
|
];
|
||||||
|
const typeKeysQuery = [`~"^(${typeKeys.join('|')})$"~".*"`];
|
||||||
|
|
||||||
const query = `
|
const query = `
|
||||||
[out:json][timeout:25];
|
[out:json][timeout:25];
|
||||||
(
|
(
|
||||||
nw["amenity"](around:${radius},${lat},${lon});
|
node(around:${radius},${lat},${lon})
|
||||||
nw["shop"](around:${radius},${lat},${lon});
|
[${typeKeysQuery}][~"^name"~"."];
|
||||||
nw["tourism"](around:${radius},${lat},${lon});
|
way(around:${radius},${lat},${lon})
|
||||||
nw["leisure"](around:${radius},${lat},${lon});
|
[${typeKeysQuery}][~"^name"~"."];
|
||||||
nw["historic"](around:${radius},${lat},${lon});
|
relation(around:${radius},${lat},${lon})
|
||||||
|
[${typeKeysQuery}][~"^name"~"."];
|
||||||
);
|
);
|
||||||
out center;
|
out center;
|
||||||
`.trim();
|
`.trim();
|
||||||
@@ -61,15 +77,20 @@ out center;
|
|||||||
}
|
}
|
||||||
|
|
||||||
normalizePoi(poi) {
|
normalizePoi(poi) {
|
||||||
|
const tags = poi.tags || {};
|
||||||
|
const type = getPlaceType(tags) || 'Point of Interest';
|
||||||
|
|
||||||
return {
|
return {
|
||||||
title: getLocalizedName(poi.tags),
|
title: getLocalizedName(tags),
|
||||||
lat: poi.lat || poi.center?.lat,
|
lat: poi.lat || poi.center?.lat,
|
||||||
lon: poi.lon || poi.center?.lon,
|
lon: poi.lon || poi.center?.lon,
|
||||||
url: poi.tags?.website,
|
url: tags.website,
|
||||||
osmId: String(poi.id),
|
osmId: String(poi.id),
|
||||||
osmType: poi.type,
|
osmType: poi.type,
|
||||||
osmTags: poi.tags || {},
|
osmTags: tags,
|
||||||
description: poi.tags?.description,
|
description: tags.description,
|
||||||
|
source: 'osm',
|
||||||
|
type: type,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -160,14 +181,43 @@ out center;
|
|||||||
normalizeOsmApiData(elements, targetId, targetType) {
|
normalizeOsmApiData(elements, targetId, targetType) {
|
||||||
if (!elements || elements.length === 0) return null;
|
if (!elements || elements.length === 0) return null;
|
||||||
|
|
||||||
const mainElement = elements.find(
|
let mainElement = elements.find(
|
||||||
(el) => String(el.id) === String(targetId) && el.type === targetType
|
(el) => String(el.id) === String(targetId) && el.type === targetType
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!mainElement) return null;
|
if (!mainElement) return null;
|
||||||
|
|
||||||
let lat = mainElement.lat;
|
// Use a separate variable for the element we want to display (tags, id, specific coords)
|
||||||
let lon = mainElement.lon;
|
// vs the element we use for geometry calculation (bbox).
|
||||||
|
let displayElement = mainElement;
|
||||||
|
|
||||||
|
// If it's a boundary relation, try to find the label or admin_centre node
|
||||||
|
// and use that as the display element (better coordinates and tags).
|
||||||
|
if (targetType === 'relation' && mainElement.members) {
|
||||||
|
const labelMember = mainElement.members.find(
|
||||||
|
(m) => m.role === 'label' && m.type === 'node'
|
||||||
|
);
|
||||||
|
const adminCentreMember = mainElement.members.find(
|
||||||
|
(m) => m.role === 'admin_centre' && m.type === 'node'
|
||||||
|
);
|
||||||
|
|
||||||
|
const targetMember = labelMember || adminCentreMember;
|
||||||
|
|
||||||
|
if (targetMember) {
|
||||||
|
const targetNode = elements.find(
|
||||||
|
(el) =>
|
||||||
|
String(el.id) === String(targetMember.ref) && el.type === 'node'
|
||||||
|
);
|
||||||
|
if (targetNode) {
|
||||||
|
displayElement = targetNode;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let lat = displayElement.lat;
|
||||||
|
let lon = displayElement.lon;
|
||||||
|
let bbox = null;
|
||||||
|
let geojson = null;
|
||||||
|
|
||||||
// If it's a way, calculate center from nodes
|
// If it's a way, calculate center from nodes
|
||||||
if (targetType === 'way' && mainElement.nodes) {
|
if (targetType === 'way' && mainElement.nodes) {
|
||||||
@@ -183,11 +233,42 @@ out center;
|
|||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
|
|
||||||
if (coords.length > 0) {
|
if (coords.length > 0) {
|
||||||
// Simple average center
|
// Only override lat/lon if we haven't switched to a specific display node
|
||||||
const sumLat = coords.reduce((sum, c) => sum + c[1], 0);
|
if (displayElement === mainElement) {
|
||||||
const sumLon = coords.reduce((sum, c) => sum + c[0], 0);
|
const sumLat = coords.reduce((sum, c) => sum + c[1], 0);
|
||||||
lat = sumLat / coords.length;
|
const sumLon = coords.reduce((sum, c) => sum + c[0], 0);
|
||||||
lon = sumLon / coords.length;
|
lat = sumLat / coords.length;
|
||||||
|
lon = sumLon / coords.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate BBox
|
||||||
|
const lats = coords.map((c) => c[1]);
|
||||||
|
const lons = coords.map((c) => c[0]);
|
||||||
|
bbox = {
|
||||||
|
minLat: Math.min(...lats),
|
||||||
|
maxLat: Math.max(...lats),
|
||||||
|
minLon: Math.min(...lons),
|
||||||
|
maxLon: Math.max(...lons),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Construct GeoJSON
|
||||||
|
if (coords.length > 1) {
|
||||||
|
const first = coords[0];
|
||||||
|
const last = coords[coords.length - 1];
|
||||||
|
const isClosed = first[0] === last[0] && first[1] === last[1];
|
||||||
|
|
||||||
|
if (isClosed) {
|
||||||
|
geojson = {
|
||||||
|
type: 'Polygon',
|
||||||
|
coordinates: [coords],
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
geojson = {
|
||||||
|
type: 'LineString',
|
||||||
|
coordinates: coords,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else if (targetType === 'relation' && mainElement.members) {
|
} else if (targetType === 'relation' && mainElement.members) {
|
||||||
// Find all nodes that are part of this relation (directly or via ways)
|
// Find all nodes that are part of this relation (directly or via ways)
|
||||||
@@ -199,6 +280,8 @@ out center;
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const segments = [];
|
||||||
|
|
||||||
mainElement.members.forEach((member) => {
|
mainElement.members.forEach((member) => {
|
||||||
if (member.type === 'node') {
|
if (member.type === 'node') {
|
||||||
const node = nodeMap.get(member.ref);
|
const node = nodeMap.get(member.ref);
|
||||||
@@ -208,31 +291,65 @@ out center;
|
|||||||
(el) => el.type === 'way' && el.id === member.ref
|
(el) => el.type === 'way' && el.id === member.ref
|
||||||
);
|
);
|
||||||
if (way && way.nodes) {
|
if (way && way.nodes) {
|
||||||
|
const wayCoords = [];
|
||||||
way.nodes.forEach((nodeId) => {
|
way.nodes.forEach((nodeId) => {
|
||||||
const node = nodeMap.get(nodeId);
|
const node = nodeMap.get(nodeId);
|
||||||
if (node) allNodes.push(node);
|
if (node) {
|
||||||
|
allNodes.push(node);
|
||||||
|
wayCoords.push([node.lon, node.lat]);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
if (wayCoords.length > 1) {
|
||||||
|
segments.push(wayCoords);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if (allNodes.length > 0) {
|
if (allNodes.length > 0) {
|
||||||
const sumLat = allNodes.reduce((sum, n) => sum + n.lat, 0);
|
// Only override lat/lon if we haven't switched to a specific display node
|
||||||
const sumLon = allNodes.reduce((sum, n) => sum + n.lon, 0);
|
if (displayElement === mainElement) {
|
||||||
lat = sumLat / allNodes.length;
|
const sumLat = allNodes.reduce((sum, n) => sum + n.lat, 0);
|
||||||
lon = sumLon / allNodes.length;
|
const sumLon = allNodes.reduce((sum, n) => sum + n.lon, 0);
|
||||||
|
lat = sumLat / allNodes.length;
|
||||||
|
lon = sumLon / allNodes.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate BBox
|
||||||
|
const lats = allNodes.map((n) => n.lat);
|
||||||
|
const lons = allNodes.map((n) => n.lon);
|
||||||
|
bbox = {
|
||||||
|
minLat: Math.min(...lats),
|
||||||
|
maxLat: Math.max(...lats),
|
||||||
|
minLon: Math.min(...lons),
|
||||||
|
maxLon: Math.max(...lons),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (segments.length > 0) {
|
||||||
|
geojson = {
|
||||||
|
type: 'MultiLineString',
|
||||||
|
coordinates: segments,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const tags = displayElement.tags || {};
|
||||||
|
const type = getPlaceType(tags) || 'Point of Interest';
|
||||||
|
|
||||||
return {
|
return {
|
||||||
title: getLocalizedName(mainElement.tags),
|
title: getLocalizedName(tags),
|
||||||
lat,
|
lat,
|
||||||
lon,
|
lon,
|
||||||
url: mainElement.tags?.website,
|
bbox,
|
||||||
osmId: String(mainElement.id),
|
geojson,
|
||||||
osmType: mainElement.type,
|
url: tags.website,
|
||||||
osmTags: mainElement.tags || {},
|
osmId: String(displayElement.id),
|
||||||
description: mainElement.tags?.description,
|
osmType: displayElement.type,
|
||||||
|
osmTags: tags,
|
||||||
|
description: tags.description,
|
||||||
|
source: 'osm',
|
||||||
|
type: type,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import Service from '@ember/service';
|
import Service from '@ember/service';
|
||||||
|
import { getPlaceType } from '../utils/osm';
|
||||||
|
import { humanizeOsmTag } from '../utils/format-text';
|
||||||
|
|
||||||
export default class PhotonService extends Service {
|
export default class PhotonService extends Service {
|
||||||
baseUrl = 'https://photon.komoot.io/api/';
|
baseUrl = 'https://photon.komoot.io/api/';
|
||||||
@@ -67,15 +69,24 @@ export default class PhotonService extends Service {
|
|||||||
R: 'relation',
|
R: 'relation',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const osmTags = { ...props };
|
||||||
|
// Photon often returns osm_key and osm_value for the main tag
|
||||||
|
if (props.osm_key && props.osm_value) {
|
||||||
|
osmTags[props.osm_key] = props.osm_value;
|
||||||
|
}
|
||||||
|
|
||||||
|
const type = getPlaceType(osmTags) || humanizeOsmTag(props.osm_value);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
title,
|
title,
|
||||||
lat,
|
lat,
|
||||||
lon,
|
lon,
|
||||||
osmId: props.osm_id,
|
osmId: props.osm_id,
|
||||||
osmType: osmTypeMap[props.osm_type] || props.osm_type, // 'node', 'way', 'relation'
|
osmType: osmTypeMap[props.osm_type] || props.osm_type, // 'node', 'way', 'relation'
|
||||||
osmTags: props, // Keep all properties as tags for now
|
osmTags,
|
||||||
description: props.name ? description : addressParts.slice(1).join(', '),
|
description: props.name ? description : addressParts.slice(1).join(', '),
|
||||||
source: 'photon',
|
source: 'photon',
|
||||||
|
type: type,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,16 +2,26 @@ import Service from '@ember/service';
|
|||||||
import { tracked } from '@glimmer/tracking';
|
import { tracked } from '@glimmer/tracking';
|
||||||
|
|
||||||
export default class SettingsService extends Service {
|
export default class SettingsService extends Service {
|
||||||
@tracked overpassApi = 'https://overpass.bke.ro/api/interpreter';
|
@tracked overpassApi = 'https://overpass-api.de/api/interpreter';
|
||||||
@tracked mapKinetic = true;
|
@tracked mapKinetic = true;
|
||||||
|
|
||||||
overpassApis = [
|
overpassApis = [
|
||||||
{ name: 'bke.ro', url: 'https://overpass.bke.ro/api/interpreter' },
|
|
||||||
{ name: 'overpass-api.de', url: 'https://overpass-api.de/api/interpreter' },
|
|
||||||
{
|
{
|
||||||
name: 'private.coffee',
|
name: 'overpass-api.de (DE)',
|
||||||
|
url: 'https://overpass-api.de/api/interpreter',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'private.coffee (AT)',
|
||||||
url: 'https://overpass.private.coffee/api/interpreter',
|
url: 'https://overpass.private.coffee/api/interpreter',
|
||||||
},
|
},
|
||||||
|
// {
|
||||||
|
// name: 'overpass.openstreetmap.us (US)',
|
||||||
|
// url: 'https://overpass.openstreetmap.us/api/interpreter'
|
||||||
|
// },
|
||||||
|
// {
|
||||||
|
// name: 'bke.ro (US)',
|
||||||
|
// url: 'https://overpass.bke.ro/api/interpreter'
|
||||||
|
// },
|
||||||
];
|
];
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
@@ -22,7 +32,15 @@ export default class SettingsService extends Service {
|
|||||||
loadSettings() {
|
loadSettings() {
|
||||||
const savedApi = localStorage.getItem('marco:overpass-api');
|
const savedApi = localStorage.getItem('marco:overpass-api');
|
||||||
if (savedApi) {
|
if (savedApi) {
|
||||||
this.overpassApi = savedApi;
|
// Check if saved API is still in the allowed list
|
||||||
|
const isValid = this.overpassApis.some((api) => api.url === savedApi);
|
||||||
|
if (isValid) {
|
||||||
|
this.overpassApi = savedApi;
|
||||||
|
} else {
|
||||||
|
// If not valid, revert to default
|
||||||
|
this.overpassApi = 'https://overpass-api.de/api/interpreter';
|
||||||
|
localStorage.setItem('marco:overpass-api', this.overpassApi);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const savedKinetic = localStorage.getItem('marco:map-kinetic');
|
const savedKinetic = localStorage.getItem('marco:map-kinetic');
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ button {
|
|||||||
|
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-family: 'Noto Serif', sans-serif;
|
font-family: 'Noto Sans', sans-serif;
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
color: #333;
|
color: #333;
|
||||||
}
|
}
|
||||||
@@ -61,7 +61,7 @@ body {
|
|||||||
left: 0;
|
left: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
height: 60px;
|
height: 60px;
|
||||||
padding: 0 1rem;
|
padding: 0.5rem 1rem;
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -69,6 +69,12 @@ body {
|
|||||||
pointer-events: none; /* Let clicks pass through to map where transparent */
|
pointer-events: none; /* Let clicks pass through to map where transparent */
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (width <= 768px) {
|
||||||
|
.app-header {
|
||||||
|
padding: 0 0.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.header-left,
|
.header-left,
|
||||||
.header-right {
|
.header-right {
|
||||||
pointer-events: auto; /* Re-enable clicks for buttons */
|
pointer-events: auto; /* Re-enable clicks for buttons */
|
||||||
@@ -203,7 +209,6 @@ body {
|
|||||||
z-index: 3200; /* Higher than Places Sidebar (3100) */
|
z-index: 3200; /* Higher than Places Sidebar (3100) */
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Settings Pane Mobile Overrides */
|
|
||||||
@media (width <= 768px) {
|
@media (width <= 768px) {
|
||||||
.settings-pane.sidebar {
|
.settings-pane.sidebar {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -340,7 +345,6 @@ body {
|
|||||||
.meta-info a {
|
.meta-info a {
|
||||||
color: #007bff;
|
color: #007bff;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
padding-bottom: 4rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.meta-info a:hover {
|
.meta-info a:hover {
|
||||||
@@ -370,10 +374,7 @@ body {
|
|||||||
.places-list {
|
.places-list {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
margin: -1rem -1rem 0 -1rem;
|
margin: -1rem -1rem 0;
|
||||||
}
|
|
||||||
|
|
||||||
.places-list li {
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.place-item {
|
.place-item {
|
||||||
@@ -507,6 +508,7 @@ body {
|
|||||||
border: 2px solid rgb(255 204 51 / 80%); /* Gold/Yellow to match markers */
|
border: 2px solid rgb(255 204 51 / 80%); /* Gold/Yellow to match markers */
|
||||||
background: rgb(255 204 51 / 20%);
|
background: rgb(255 204 51 / 20%);
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
|
||||||
/* Use translate3d for GPU acceleration on iOS */
|
/* Use translate3d for GPU acceleration on iOS */
|
||||||
transform: translate3d(-50%, -50%, 0);
|
transform: translate3d(-50%, -50%, 0);
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
@@ -539,42 +541,58 @@ body {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Zoom Control - Moved to bottom right above attribution */
|
/* Map controls */
|
||||||
.ol-zoom {
|
|
||||||
top: auto !important;
|
.ol-control.ol-attribution {
|
||||||
left: auto !important;
|
bottom: 1rem;
|
||||||
bottom: 2.5em;
|
|
||||||
right: 0.5em;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.ol-touch .ol-zoom {
|
.ol-touch .ol-control.ol-attribution {
|
||||||
bottom: 3.5em;
|
bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ol-control.ol-zoom {
|
||||||
|
bottom: 3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ol-touch .ol-control.ol-zoom {
|
||||||
|
bottom: 3.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Locate Control - Above Zoom */
|
|
||||||
.ol-control.ol-locate {
|
.ol-control.ol-locate {
|
||||||
top: auto !important;
|
bottom: 6.5rem;
|
||||||
left: auto !important;
|
|
||||||
bottom: 6.5em;
|
|
||||||
right: 0.5em;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.ol-touch .ol-control.ol-locate {
|
.ol-touch .ol-control.ol-locate {
|
||||||
bottom: 8.5em;
|
bottom: 8.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Rotate Control - Above Locate */
|
.ol-control.ol-rotate {
|
||||||
.ol-rotate {
|
bottom: 9rem;
|
||||||
top: auto !important;
|
|
||||||
left: auto !important;
|
|
||||||
bottom: 9em;
|
|
||||||
right: 0.5em;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.ol-touch .ol-rotate {
|
.ol-touch .ol-control.ol-rotate {
|
||||||
bottom: 11.5em;
|
bottom: 11.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ol-control.ol-attribution,
|
||||||
|
.ol-control.ol-zoom,
|
||||||
|
.ol-control.ol-locate,
|
||||||
|
.ol-control.ol-rotate {
|
||||||
|
top: auto;
|
||||||
|
left: auto;
|
||||||
|
right: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ol-touch .ol-control.ol-attribution,
|
||||||
|
.ol-touch .ol-control.ol-zoom,
|
||||||
|
.ol-touch .ol-control.ol-locate,
|
||||||
|
.ol-touch .ol-control.ol-rotate {
|
||||||
|
right: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Icons */
|
||||||
|
|
||||||
span.icon {
|
span.icon {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
}
|
}
|
||||||
@@ -593,13 +611,22 @@ span.icon {
|
|||||||
stroke-linejoin: round;
|
stroke-linejoin: round;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.icon-filled svg {
|
||||||
|
stroke: none;
|
||||||
|
fill: currentcolor;
|
||||||
|
}
|
||||||
|
|
||||||
.content-with-icon {
|
.content-with-icon {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
align-items: center;
|
align-items: flex-start;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.content-with-icon .icon {
|
||||||
|
margin-top: 0.15rem;
|
||||||
|
}
|
||||||
|
|
||||||
/* Selected Pin Animation */
|
/* Selected Pin Animation */
|
||||||
.selected-pin-container {
|
.selected-pin-container {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
@@ -670,6 +697,7 @@ span.icon {
|
|||||||
/* Map Crosshair for "Create Place" mode */
|
/* Map Crosshair for "Create Place" mode */
|
||||||
.map-crosshair {
|
.map-crosshair {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
|
||||||
/* Default Center */
|
/* Default Center */
|
||||||
top: 50%;
|
top: 50%;
|
||||||
left: 50%;
|
left: 50%;
|
||||||
@@ -690,8 +718,11 @@ span.icon {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Sidebar is open (Desktop: Left 300px) */
|
/* Sidebar is open (Desktop: Left 300px) */
|
||||||
|
|
||||||
/* We want to center in the remaining space (width - 300px) */
|
/* We want to center in the remaining space (width - 300px) */
|
||||||
|
|
||||||
/* Center X = 300 + (width - 300) / 2 = 300 + width/2 - 150 = width/2 + 150 */
|
/* Center X = 300 + (width - 300) / 2 = 300 + width/2 - 150 = width/2 + 150 */
|
||||||
|
|
||||||
/* So shift left by 150px from center */
|
/* So shift left by 150px from center */
|
||||||
.map-container.sidebar-open .map-crosshair {
|
.map-container.sidebar-open .map-crosshair {
|
||||||
left: calc(50% + 150px);
|
left: calc(50% + 150px);
|
||||||
@@ -699,6 +730,7 @@ span.icon {
|
|||||||
|
|
||||||
@media (width <= 768px) {
|
@media (width <= 768px) {
|
||||||
/* Sidebar/Bottom Sheet is open (Mobile: Bottom 50%) */
|
/* Sidebar/Bottom Sheet is open (Mobile: Bottom 50%) */
|
||||||
|
|
||||||
/* Center Y = (height/2) / 2 = height/4 = 25% */
|
/* Center Y = (height/2) / 2 = height/4 = 25% */
|
||||||
.map-container.sidebar-open .map-crosshair {
|
.map-container.sidebar-open .map-crosshair {
|
||||||
left: 50%; /* Reset desktop shift */
|
left: 50%; /* Reset desktop shift */
|
||||||
@@ -752,10 +784,9 @@ button.create-place {
|
|||||||
z-index: 3002; /* Higher than menu button to be safe */
|
z-index: 3002; /* Higher than menu button to be safe */
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (width <= 768px) {
|
||||||
.search-box {
|
.search-box {
|
||||||
max-width: calc(100vw - 80px); /* Smaller on mobile but wider than before */
|
max-width: calc(100vw - 65px);
|
||||||
margin-left: 0.5rem;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -764,14 +795,14 @@ button.create-place {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
background: white;
|
background: white;
|
||||||
border-radius: 24px; /* Pill shape */
|
border-radius: 24px; /* Pill shape */
|
||||||
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.15);
|
box-shadow: 0 2px 5px rgb(0 0 0 / 15%);
|
||||||
padding: 0 0.5rem;
|
padding: 0 0.5rem;
|
||||||
height: 48px; /* Slightly taller for touch targets */
|
height: 48px; /* Slightly taller for touch targets */
|
||||||
transition: box-shadow 0.2s;
|
transition: box-shadow 0.2s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.search-form:focus-within {
|
.search-form:focus-within {
|
||||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
box-shadow: 0 4px 12px rgb(0 0 0 / 15%);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Integrated Menu Button */
|
/* Integrated Menu Button */
|
||||||
@@ -789,7 +820,7 @@ button.create-place {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.menu-btn-integrated:hover {
|
.menu-btn-integrated:hover {
|
||||||
background: rgba(0, 0, 0, 0.05);
|
background: rgb(0 0 0 / 5%);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Fallback Search Icon (Left) */
|
/* Fallback Search Icon (Left) */
|
||||||
@@ -813,6 +844,7 @@ button.create-place {
|
|||||||
outline: none;
|
outline: none;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 0 4px;
|
padding: 0 4px;
|
||||||
|
|
||||||
/* Remove native search cancel button in WebKit */
|
/* Remove native search cancel button in WebKit */
|
||||||
-webkit-appearance: none;
|
-webkit-appearance: none;
|
||||||
}
|
}
|
||||||
@@ -834,25 +866,11 @@ button.create-place {
|
|||||||
color: #5f6368;
|
color: #5f6368;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
margin-left: 4px;
|
margin-left: 4px;
|
||||||
border-left: 1px solid #ddd; /* Separator like Google Maps */
|
|
||||||
padding-left: 12px;
|
|
||||||
border-radius: 0; /* Reset for separator look */
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-submit-btn:hover {
|
|
||||||
/* No background on hover if we use separator style, or maybe just change icon color */
|
|
||||||
color: #1a73e8; /* Blue on hover */
|
|
||||||
}
|
|
||||||
|
|
||||||
/* If we want the separator style, we need to adjust border-radius carefully or use a pseudo element */
|
|
||||||
/* Let's stick to a simple button for now, maybe without the separator if it looks cleaner */
|
|
||||||
.search-submit-btn {
|
|
||||||
border-left: none; /* Remove separator for cleaner look */
|
|
||||||
padding-left: 8px;
|
padding-left: 8px;
|
||||||
border-radius: 50%;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.search-submit-btn:hover {
|
.search-submit-btn:hover {
|
||||||
background: rgba(0, 0, 0, 0.05);
|
background: rgb(0 0 0 / 5%);
|
||||||
color: #333;
|
color: #333;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -870,7 +888,7 @@ button.create-place {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.search-clear-btn:hover {
|
.search-clear-btn:hover {
|
||||||
background: rgba(0, 0, 0, 0.05);
|
background: rgb(0 0 0 / 5%);
|
||||||
color: #333;
|
color: #333;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -883,7 +901,7 @@ button.create-place {
|
|||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
background: white;
|
background: white;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
box-shadow: 0 4px 12px rgb(0 0 0 / 15%);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
max-height: 400px;
|
max-height: 400px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { humanizeOsmTag } from './format-text';
|
||||||
|
|
||||||
export function getLocalizedName(tags, defaultName = 'Untitled Place') {
|
export function getLocalizedName(tags, defaultName = 'Untitled Place') {
|
||||||
if (!tags) return defaultName;
|
if (!tags) return defaultName;
|
||||||
|
|
||||||
@@ -30,3 +32,39 @@ export function getLocalizedName(tags, defaultName = 'Untitled Place') {
|
|||||||
// 5. Final fallback
|
// 5. Final fallback
|
||||||
return defaultName;
|
return defaultName;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const PLACE_TYPE_KEYS = [
|
||||||
|
'amenity',
|
||||||
|
'shop',
|
||||||
|
'tourism',
|
||||||
|
'historic',
|
||||||
|
'leisure',
|
||||||
|
'office',
|
||||||
|
'craft',
|
||||||
|
'building',
|
||||||
|
'landuse',
|
||||||
|
'public_transport',
|
||||||
|
'highway',
|
||||||
|
'aeroway',
|
||||||
|
'waterway',
|
||||||
|
'natural',
|
||||||
|
'place',
|
||||||
|
'border_type',
|
||||||
|
'admin_title',
|
||||||
|
];
|
||||||
|
|
||||||
|
export function getPlaceType(tags) {
|
||||||
|
if (!tags) return null;
|
||||||
|
|
||||||
|
for (const key of PLACE_TYPE_KEYS) {
|
||||||
|
const value = tags[key];
|
||||||
|
if (value) {
|
||||||
|
if (value === 'yes') {
|
||||||
|
return humanizeOsmTag(key);
|
||||||
|
}
|
||||||
|
return humanizeOsmTag(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|||||||
15
index.html
15
index.html
@@ -3,9 +3,22 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<title>Marco</title>
|
<title>Marco</title>
|
||||||
<meta name="description" content="">
|
<meta name="description" content="Unhosted maps app that respects your privacy and choices.">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
|
||||||
|
<!-- Open Graph -->
|
||||||
|
<meta property="og:title" content="Marco">
|
||||||
|
<meta property="og:description" content="Unhosted maps app that respects your privacy and choices.">
|
||||||
|
<meta property="og:type" content="website">
|
||||||
|
<meta property="og:url" content="https://marco.kosmos.org">
|
||||||
|
<meta property="og:image" content="https://marco.kosmos.org/icons/icon-512.png">
|
||||||
|
|
||||||
|
<!-- Twitter Card -->
|
||||||
|
<meta name="twitter:card" content="summary">
|
||||||
|
<meta name="twitter:title" content="Marco">
|
||||||
|
<meta name="twitter:description" content="Unhosted maps app that respects your privacy and choices.">
|
||||||
|
<meta name="twitter:image" content="https://marco.kosmos.org/icons/icon-512.png">
|
||||||
|
|
||||||
<!-- App identity -->
|
<!-- App identity -->
|
||||||
<meta name="application-name" content="Marco">
|
<meta name="application-name" content="Marco">
|
||||||
<meta name="apple-mobile-web-app-title" content="Marco">
|
<meta name="apple-mobile-web-app-title" content="Marco">
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "marco",
|
"name": "marco",
|
||||||
"version": "1.11.4",
|
"version": "1.12.2",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "Unhosted maps app",
|
"description": "Unhosted maps app",
|
||||||
"repository": {
|
"repository": {
|
||||||
@@ -25,9 +25,10 @@
|
|||||||
"format": "prettier . --cache --write",
|
"format": "prettier . --cache --write",
|
||||||
"lint": "concurrently \"pnpm:lint:*(!fix)\" --names \"lint:\" --prefixColors auto",
|
"lint": "concurrently \"pnpm:lint:*(!fix)\" --names \"lint:\" --prefixColors auto",
|
||||||
"lint:css": "stylelint \"**/*.css\"",
|
"lint:css": "stylelint \"**/*.css\"",
|
||||||
"lint:css:fix": "concurrently \"pnpm:lint:css -- --fix\"",
|
"lint:css:fix": "stylelint \"**/*.css\" --fix",
|
||||||
"lint:fix": "concurrently \"pnpm:lint:*:fix\" --names \"fix:\" --prefixColors auto && pnpm format",
|
"lint:fix": "concurrently \"pnpm:lint:*:fix\" --names \"fix:\" --prefixColors auto && pnpm format",
|
||||||
"lint:format": "prettier . --cache --check",
|
"lint:format": "prettier . --cache --check",
|
||||||
|
"lint:format:fix": "prettier . --cache --write",
|
||||||
"lint:hbs": "ember-template-lint .",
|
"lint:hbs": "ember-template-lint .",
|
||||||
"lint:hbs:fix": "ember-template-lint . --fix",
|
"lint:hbs:fix": "ember-template-lint . --fix",
|
||||||
"lint:js": "eslint . --cache",
|
"lint:js": "eslint . --cache",
|
||||||
|
|||||||
2
release/assets/main-BpHxSZoe.js
Normal file
2
release/assets/main-BpHxSZoe.js
Normal file
File diff suppressed because one or more lines are too long
1
release/assets/main-DoLYcE7E.css
Normal file
1
release/assets/main-DoLYcE7E.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
@@ -3,9 +3,22 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<title>Marco</title>
|
<title>Marco</title>
|
||||||
<meta name="description" content="">
|
<meta name="description" content="Unhosted maps app that respects your privacy and choices.">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
|
||||||
|
<!-- Open Graph -->
|
||||||
|
<meta property="og:title" content="Marco">
|
||||||
|
<meta property="og:description" content="Unhosted maps app that respects your privacy and choices.">
|
||||||
|
<meta property="og:type" content="website">
|
||||||
|
<meta property="og:url" content="https://marco.kosmos.org">
|
||||||
|
<meta property="og:image" content="https://marco.kosmos.org/icons/icon-512.png">
|
||||||
|
|
||||||
|
<!-- Twitter Card -->
|
||||||
|
<meta name="twitter:card" content="summary">
|
||||||
|
<meta name="twitter:title" content="Marco">
|
||||||
|
<meta name="twitter:description" content="Unhosted maps app that respects your privacy and choices.">
|
||||||
|
<meta name="twitter:image" content="https://marco.kosmos.org/icons/icon-512.png">
|
||||||
|
|
||||||
<!-- App identity -->
|
<!-- App identity -->
|
||||||
<meta name="application-name" content="Marco">
|
<meta name="application-name" content="Marco">
|
||||||
<meta name="apple-mobile-web-app-title" content="Marco">
|
<meta name="apple-mobile-web-app-title" content="Marco">
|
||||||
@@ -26,8 +39,8 @@
|
|||||||
<meta name="msapplication-TileColor" content="#F6E9A6">
|
<meta name="msapplication-TileColor" content="#F6E9A6">
|
||||||
<meta name="msapplication-TileImage" content="/icons/icon-144.png">
|
<meta name="msapplication-TileImage" content="/icons/icon-144.png">
|
||||||
|
|
||||||
<script type="module" crossorigin src="/assets/main-ji2SNMnp.js"></script>
|
<script type="module" crossorigin src="/assets/main-BpHxSZoe.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/main-G8wPYi_P.css">
|
<link rel="stylesheet" crossorigin href="/assets/main-DoLYcE7E.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { module, test } from 'qunit';
|
import { module, test } from 'qunit';
|
||||||
import { visit, currentURL, click, settled } from '@ember/test-helpers';
|
import { visit, currentURL, click } from '@ember/test-helpers';
|
||||||
import { setupApplicationTest } from 'marco/tests/helpers';
|
import { setupApplicationTest } from 'marco/tests/helpers';
|
||||||
import Service from '@ember/service';
|
import Service from '@ember/service';
|
||||||
import sinon from 'sinon';
|
import sinon from 'sinon';
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ module('Acceptance | search', function (hooks) {
|
|||||||
await visit('/search?q=Berlin');
|
await visit('/search?q=Berlin');
|
||||||
|
|
||||||
assert.strictEqual(currentURL(), '/search?q=Berlin');
|
assert.strictEqual(currentURL(), '/search?q=Berlin');
|
||||||
|
assert.dom('.sidebar-header h2').includesText('Results');
|
||||||
assert.dom('.places-list li').exists({ count: 2 });
|
assert.dom('.places-list li').exists({ count: 2 });
|
||||||
assert.dom('.places-list li:first-child .place-name').hasText('Berlin');
|
assert.dom('.places-list li:first-child .place-name').hasText('Berlin');
|
||||||
});
|
});
|
||||||
@@ -99,6 +100,7 @@ module('Acceptance | search', function (hooks) {
|
|||||||
await visit('/search?lat=52.52&lon=13.405');
|
await visit('/search?lat=52.52&lon=13.405');
|
||||||
|
|
||||||
assert.strictEqual(currentURL(), '/search?lat=52.52&lon=13.405');
|
assert.strictEqual(currentURL(), '/search?lat=52.52&lon=13.405');
|
||||||
|
assert.dom('.sidebar-header h2').includesText('Nearby');
|
||||||
assert.dom('.places-list li').exists({ count: 1 });
|
assert.dom('.places-list li').exists({ count: 1 });
|
||||||
assert.dom('.places-list li .place-name').hasText('Nearby Cafe');
|
assert.dom('.places-list li .place-name').hasText('Nearby Cafe');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -37,7 +37,9 @@ module('Integration | Component | search-box', function (hooks) {
|
|||||||
this.owner.register('service:router', MockRouterService);
|
this.owner.register('service:router', MockRouterService);
|
||||||
|
|
||||||
this.noop = () => {};
|
this.noop = () => {};
|
||||||
await render(<template><SearchBox @onToggleMenu={{this.noop}} /></template>);
|
await render(
|
||||||
|
<template><SearchBox @onToggleMenu={{this.noop}} /></template>
|
||||||
|
);
|
||||||
|
|
||||||
assert.dom('.search-input').exists();
|
assert.dom('.search-input').exists();
|
||||||
assert.dom('.search-results-popover').doesNotExist();
|
assert.dom('.search-results-popover').doesNotExist();
|
||||||
@@ -86,7 +88,9 @@ module('Integration | Component | search-box', function (hooks) {
|
|||||||
this.owner.register('service:router', MockRouterService);
|
this.owner.register('service:router', MockRouterService);
|
||||||
|
|
||||||
this.noop = () => {};
|
this.noop = () => {};
|
||||||
await render(<template><SearchBox @onToggleMenu={{this.noop}} /></template>);
|
await render(
|
||||||
|
<template><SearchBox @onToggleMenu={{this.noop}} /></template>
|
||||||
|
);
|
||||||
|
|
||||||
await fillIn('.search-input', 'berlin');
|
await fillIn('.search-input', 'berlin');
|
||||||
await click('.search-input'); // Focus
|
await click('.search-input'); // Focus
|
||||||
@@ -118,7 +122,9 @@ module('Integration | Component | search-box', function (hooks) {
|
|||||||
this.owner.register('service:photon', MockPhotonService);
|
this.owner.register('service:photon', MockPhotonService);
|
||||||
|
|
||||||
this.noop = () => {};
|
this.noop = () => {};
|
||||||
await render(<template><SearchBox @onToggleMenu={{this.noop}} /></template>);
|
await render(
|
||||||
|
<template><SearchBox @onToggleMenu={{this.noop}} /></template>
|
||||||
|
);
|
||||||
|
|
||||||
await fillIn('.search-input', 'cafe');
|
await fillIn('.search-input', 'cafe');
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { module, test } from 'qunit';
|
import { module, test } from 'qunit';
|
||||||
import { setupTest } from 'marco/tests/helpers';
|
import { setupTest } from 'marco/tests/helpers';
|
||||||
|
import Service from '@ember/service';
|
||||||
|
|
||||||
module('Unit | Route | place', function (hooks) {
|
module('Unit | Route | place', function (hooks) {
|
||||||
setupTest(hooks);
|
setupTest(hooks);
|
||||||
@@ -8,4 +9,120 @@ module('Unit | Route | place', function (hooks) {
|
|||||||
let route = this.owner.lookup('route:place');
|
let route = this.owner.lookup('route:place');
|
||||||
assert.ok(route);
|
assert.ok(route);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('afterModel enriches model with missing geometry', async function (assert) {
|
||||||
|
let route = this.owner.lookup('route:place');
|
||||||
|
|
||||||
|
// Mock Services
|
||||||
|
let fetchCalled = false;
|
||||||
|
let selectPlaceCalled = false;
|
||||||
|
|
||||||
|
class OsmStub extends Service {
|
||||||
|
async fetchOsmObject(id, type) {
|
||||||
|
fetchCalled = true;
|
||||||
|
assert.strictEqual(id, '123', 'Correct ID passed');
|
||||||
|
assert.strictEqual(type, 'way', 'Correct Type passed');
|
||||||
|
return {
|
||||||
|
osmId: '123',
|
||||||
|
osmType: 'way',
|
||||||
|
geojson: { type: 'Polygon', coordinates: [] },
|
||||||
|
tags: { updated: 'true' },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class MapUiStub extends Service {
|
||||||
|
selectPlace() {
|
||||||
|
selectPlaceCalled = true;
|
||||||
|
}
|
||||||
|
stopSearch() {}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.owner.register('service:osm', OsmStub);
|
||||||
|
this.owner.register('service:map-ui', MapUiStub);
|
||||||
|
|
||||||
|
// Initial partial model (from search)
|
||||||
|
let model = {
|
||||||
|
osmId: '123',
|
||||||
|
osmType: 'way',
|
||||||
|
title: 'Partial Place',
|
||||||
|
// No geojson
|
||||||
|
};
|
||||||
|
|
||||||
|
await route.afterModel(model);
|
||||||
|
|
||||||
|
assert.ok(fetchCalled, 'fetchOsmObject should be called');
|
||||||
|
assert.ok(selectPlaceCalled, 'selectPlace should be called');
|
||||||
|
assert.ok(model.geojson, 'Model should now have geojson');
|
||||||
|
assert.strictEqual(
|
||||||
|
model.tags.updated,
|
||||||
|
'true',
|
||||||
|
'Model should have updated tags'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('afterModel skips fetch if geometry exists', async function (assert) {
|
||||||
|
let route = this.owner.lookup('route:place');
|
||||||
|
|
||||||
|
let fetchCalled = false;
|
||||||
|
|
||||||
|
class OsmStub extends Service {
|
||||||
|
async fetchOsmObject() {
|
||||||
|
fetchCalled = true;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class MapUiStub extends Service {
|
||||||
|
selectPlace() {}
|
||||||
|
stopSearch() {}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.owner.register('service:osm', OsmStub);
|
||||||
|
this.owner.register('service:map-ui', MapUiStub);
|
||||||
|
|
||||||
|
let model = {
|
||||||
|
osmId: '456',
|
||||||
|
osmType: 'relation',
|
||||||
|
geojson: { type: 'MultiLineString' },
|
||||||
|
};
|
||||||
|
|
||||||
|
await route.afterModel(model);
|
||||||
|
|
||||||
|
assert.notOk(
|
||||||
|
fetchCalled,
|
||||||
|
'fetchOsmObject should NOT be called if geojson exists'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('afterModel skips fetch for nodes even if geometry is missing', async function (assert) {
|
||||||
|
let route = this.owner.lookup('route:place');
|
||||||
|
|
||||||
|
let fetchCalled = false;
|
||||||
|
|
||||||
|
class OsmStub extends Service {
|
||||||
|
async fetchOsmObject() {
|
||||||
|
fetchCalled = true;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class MapUiStub extends Service {
|
||||||
|
selectPlace() {}
|
||||||
|
stopSearch() {}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.owner.register('service:osm', OsmStub);
|
||||||
|
this.owner.register('service:map-ui', MapUiStub);
|
||||||
|
|
||||||
|
let model = {
|
||||||
|
osmId: '789',
|
||||||
|
osmType: 'node',
|
||||||
|
// No geojson, but it's a node
|
||||||
|
};
|
||||||
|
|
||||||
|
await route.afterModel(model);
|
||||||
|
|
||||||
|
assert.notOk(fetchCalled, 'fetchOsmObject should NOT be called for nodes');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ module('Unit | Service | osm', function (hooks) {
|
|||||||
assert.strictEqual(result.osmType, 'way');
|
assert.strictEqual(result.osmType, 'way');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('normalizeOsmApiData calculates centroid for relations with member nodes', function (assert) {
|
test('normalizeOsmApiData prioritizes label node for relations', function (assert) {
|
||||||
let service = this.owner.lookup('service:osm');
|
let service = this.owner.lookup('service:osm');
|
||||||
const elements = [
|
const elements = [
|
||||||
{
|
{
|
||||||
@@ -64,17 +64,73 @@ module('Unit | Service | osm', function (hooks) {
|
|||||||
],
|
],
|
||||||
tags: { name: 'Test Relation' },
|
tags: { name: 'Test Relation' },
|
||||||
},
|
},
|
||||||
{ id: 1, type: 'node', lat: 10, lon: 10 },
|
{ id: 1, type: 'node', lat: 10, lon: 10, tags: { name: 'Admin Centre' } },
|
||||||
{ id: 2, type: 'node', lat: 30, lon: 30 },
|
{ id: 2, type: 'node', lat: 30, lon: 30, tags: { name: 'Label Node' } },
|
||||||
];
|
];
|
||||||
|
|
||||||
const result = service.normalizeOsmApiData(elements, 789, 'relation');
|
const result = service.normalizeOsmApiData(elements, 789, 'relation');
|
||||||
|
|
||||||
assert.strictEqual(result.title, 'Test Relation');
|
assert.strictEqual(result.title, 'Label Node');
|
||||||
assert.strictEqual(result.lat, 20); // (10+30)/2
|
assert.strictEqual(result.lat, 30);
|
||||||
assert.strictEqual(result.lon, 20); // (10+30)/2
|
assert.strictEqual(result.lon, 30);
|
||||||
assert.strictEqual(result.osmId, '789');
|
assert.strictEqual(result.osmId, '2');
|
||||||
assert.strictEqual(result.osmType, 'relation');
|
assert.strictEqual(result.osmType, 'node');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('normalizeOsmApiData falls back to admin_centre node for relations', function (assert) {
|
||||||
|
let service = this.owner.lookup('service:osm');
|
||||||
|
const elements = [
|
||||||
|
{
|
||||||
|
id: 789,
|
||||||
|
type: 'relation',
|
||||||
|
members: [{ type: 'node', ref: 1, role: 'admin_centre' }],
|
||||||
|
tags: { name: 'Test Relation' },
|
||||||
|
},
|
||||||
|
{ id: 1, type: 'node', lat: 10, lon: 10, tags: { name: 'Admin Centre' } },
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = service.normalizeOsmApiData(elements, 789, 'relation');
|
||||||
|
|
||||||
|
assert.strictEqual(result.title, 'Admin Centre');
|
||||||
|
assert.strictEqual(result.lat, 10);
|
||||||
|
assert.strictEqual(result.lon, 10);
|
||||||
|
assert.strictEqual(result.osmId, '1');
|
||||||
|
assert.strictEqual(result.osmType, 'node');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('normalizeOsmApiData calculates bbox for relations', function (assert) {
|
||||||
|
let service = this.owner.lookup('service:osm');
|
||||||
|
const elements = [
|
||||||
|
{
|
||||||
|
id: 789,
|
||||||
|
type: 'relation',
|
||||||
|
members: [
|
||||||
|
{ type: 'node', ref: 1, role: 'label' },
|
||||||
|
{ type: 'node', ref: 2, role: 'border' },
|
||||||
|
{ type: 'node', ref: 3, role: 'border' },
|
||||||
|
],
|
||||||
|
tags: { name: 'Test Relation' },
|
||||||
|
},
|
||||||
|
{ id: 1, type: 'node', lat: 10, lon: 10, tags: { name: 'Label' } },
|
||||||
|
{ id: 2, type: 'node', lat: 0, lon: 0 },
|
||||||
|
{ id: 3, type: 'node', lat: 20, lon: 20 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = service.normalizeOsmApiData(elements, 789, 'relation');
|
||||||
|
|
||||||
|
// Should prioritize admin centre for ID/Title/Center
|
||||||
|
assert.strictEqual(result.title, 'Label');
|
||||||
|
assert.strictEqual(result.lat, 10);
|
||||||
|
assert.strictEqual(result.lon, 10);
|
||||||
|
assert.strictEqual(result.osmId, '1');
|
||||||
|
assert.strictEqual(result.osmType, 'node');
|
||||||
|
|
||||||
|
// BUT should calculate BBox from ALL members (0,0 to 20,20)
|
||||||
|
assert.ok(result.bbox, 'BBox should be present');
|
||||||
|
assert.strictEqual(result.bbox.minLat, 0);
|
||||||
|
assert.strictEqual(result.bbox.minLon, 0);
|
||||||
|
assert.strictEqual(result.bbox.maxLat, 20);
|
||||||
|
assert.strictEqual(result.bbox.maxLon, 20);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('normalizeOsmApiData calculates centroid for relations with member ways', function (assert) {
|
test('normalizeOsmApiData calculates centroid for relations with member ways', function (assert) {
|
||||||
@@ -110,4 +166,89 @@ module('Unit | Service | osm', function (hooks) {
|
|||||||
assert.strictEqual(result.osmId, '999');
|
assert.strictEqual(result.osmId, '999');
|
||||||
assert.strictEqual(result.osmType, 'relation');
|
assert.strictEqual(result.osmType, 'relation');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('normalizeOsmApiData creates GeoJSON for ways', function (assert) {
|
||||||
|
let service = this.owner.lookup('service:osm');
|
||||||
|
const elements = [
|
||||||
|
{
|
||||||
|
id: 456,
|
||||||
|
type: 'way',
|
||||||
|
nodes: [1, 2, 3],
|
||||||
|
tags: { name: 'Test Way' },
|
||||||
|
},
|
||||||
|
{ id: 1, type: 'node', lat: 0, lon: 0 },
|
||||||
|
{ id: 2, type: 'node', lat: 10, lon: 10 },
|
||||||
|
{ id: 3, type: 'node', lat: 0, lon: 0 }, // Closed loop
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = service.normalizeOsmApiData(elements, 456, 'way');
|
||||||
|
|
||||||
|
assert.ok(result.geojson, 'GeoJSON should be present');
|
||||||
|
assert.strictEqual(
|
||||||
|
result.geojson.type,
|
||||||
|
'Polygon',
|
||||||
|
'Closed way should be a Polygon'
|
||||||
|
);
|
||||||
|
assert.strictEqual(
|
||||||
|
result.geojson.coordinates[0].length,
|
||||||
|
3,
|
||||||
|
'Should have 3 coordinates'
|
||||||
|
);
|
||||||
|
assert.deepEqual(result.geojson.coordinates[0][0], [0, 0]);
|
||||||
|
assert.deepEqual(result.geojson.coordinates[0][1], [10, 10]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('normalizeOsmApiData creates GeoJSON MultiLineString for relations', function (assert) {
|
||||||
|
let service = this.owner.lookup('service:osm');
|
||||||
|
/*
|
||||||
|
Relation 999
|
||||||
|
-> Way 888 (0,0 -> 10,10)
|
||||||
|
-> Way 777 (20,20 -> 30,30)
|
||||||
|
*/
|
||||||
|
const elements = [
|
||||||
|
{
|
||||||
|
id: 999,
|
||||||
|
type: 'relation',
|
||||||
|
members: [
|
||||||
|
{ type: 'way', ref: 888, role: 'outer' },
|
||||||
|
{ type: 'way', ref: 777, role: 'inner' },
|
||||||
|
],
|
||||||
|
tags: { name: 'Complex Relation' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 888,
|
||||||
|
type: 'way',
|
||||||
|
nodes: [1, 2],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 777,
|
||||||
|
type: 'way',
|
||||||
|
nodes: [3, 4],
|
||||||
|
},
|
||||||
|
{ id: 1, type: 'node', lat: 0, lon: 0 },
|
||||||
|
{ id: 2, type: 'node', lat: 10, lon: 10 },
|
||||||
|
{ id: 3, type: 'node', lat: 20, lon: 20 },
|
||||||
|
{ id: 4, type: 'node', lat: 30, lon: 30 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = service.normalizeOsmApiData(elements, 999, 'relation');
|
||||||
|
|
||||||
|
assert.ok(result.geojson, 'GeoJSON should be present');
|
||||||
|
assert.strictEqual(result.geojson.type, 'MultiLineString');
|
||||||
|
assert.strictEqual(
|
||||||
|
result.geojson.coordinates.length,
|
||||||
|
2,
|
||||||
|
'Should have 2 segments'
|
||||||
|
);
|
||||||
|
// Check first segment (Way 888)
|
||||||
|
assert.deepEqual(result.geojson.coordinates[0], [
|
||||||
|
[0, 0],
|
||||||
|
[10, 10],
|
||||||
|
]);
|
||||||
|
// Check second segment (Way 777)
|
||||||
|
assert.deepEqual(result.geojson.coordinates[1], [
|
||||||
|
[20, 20],
|
||||||
|
[30, 30],
|
||||||
|
]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
104
tests/unit/utils/osm-test.js
Normal file
104
tests/unit/utils/osm-test.js
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
import { module, test } from 'qunit';
|
||||||
|
import { setupTest } from 'marco/tests/helpers';
|
||||||
|
import { getLocalizedName, getPlaceType } from 'marco/utils/osm';
|
||||||
|
|
||||||
|
module('Unit | Utility | osm', function (hooks) {
|
||||||
|
setupTest(hooks);
|
||||||
|
|
||||||
|
test('getLocalizedName returns default name if tags are missing', function (assert) {
|
||||||
|
const result = getLocalizedName(null);
|
||||||
|
assert.strictEqual(result, 'Untitled Place');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getLocalizedName returns name tag', function (assert) {
|
||||||
|
const tags = { name: 'Foo' };
|
||||||
|
const result = getLocalizedName(tags);
|
||||||
|
assert.strictEqual(result, 'Foo');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getLocalizedName falls back to name:en if name is missing', function (assert) {
|
||||||
|
const tags = { 'name:en': 'English Name' };
|
||||||
|
const result = getLocalizedName(tags);
|
||||||
|
assert.strictEqual(result, 'English Name');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getLocalizedName returns local name (name tag) if no preferred language match found', function (assert) {
|
||||||
|
// Assuming the test environment doesn't have 'fr' as a preferred language
|
||||||
|
const tags = { name: 'Local Name', 'name:fr': 'French Name' };
|
||||||
|
// Temporarily mock navigator to ensure no match
|
||||||
|
const originalLanguages = navigator.languages;
|
||||||
|
const originalLanguage = navigator.language;
|
||||||
|
Object.defineProperty(navigator, 'languages', {
|
||||||
|
value: ['es'],
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
Object.defineProperty(navigator, 'language', {
|
||||||
|
value: 'es',
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = getLocalizedName(tags);
|
||||||
|
assert.strictEqual(result, 'Local Name');
|
||||||
|
} finally {
|
||||||
|
// Restore
|
||||||
|
Object.defineProperty(navigator, 'languages', {
|
||||||
|
value: originalLanguages,
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
Object.defineProperty(navigator, 'language', {
|
||||||
|
value: originalLanguage,
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getLocalizedName matches user preferred language', function (assert) {
|
||||||
|
const tags = {
|
||||||
|
name: 'Standard Name',
|
||||||
|
'name:de': 'Deutscher Name',
|
||||||
|
'name:fr': 'Nom Français',
|
||||||
|
};
|
||||||
|
|
||||||
|
const originalLanguages = navigator.languages;
|
||||||
|
Object.defineProperty(navigator, 'languages', {
|
||||||
|
value: ['de', 'en'],
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = getLocalizedName(tags);
|
||||||
|
assert.strictEqual(result, 'Deutscher Name');
|
||||||
|
} finally {
|
||||||
|
Object.defineProperty(navigator, 'languages', {
|
||||||
|
value: originalLanguages,
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getPlaceType returns value for normal tags', function (assert) {
|
||||||
|
const tags = { amenity: 'restaurant' };
|
||||||
|
const result = getPlaceType(tags);
|
||||||
|
assert.strictEqual(result, 'Restaurant');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getPlaceType returns key name if value is "yes"', function (assert) {
|
||||||
|
const tags = { building: 'yes' };
|
||||||
|
const result = getPlaceType(tags);
|
||||||
|
assert.strictEqual(result, 'Building');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getPlaceType prioritizes order (amenity > shop > building)', function (assert) {
|
||||||
|
// If something is both a shop and a building, it should be a shop
|
||||||
|
const tags = { building: 'yes', shop: 'supermarket' };
|
||||||
|
const result = getPlaceType(tags);
|
||||||
|
assert.strictEqual(result, 'Supermarket');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getPlaceType returns null if no known type found', function (assert) {
|
||||||
|
const tags = { foo: 'bar' };
|
||||||
|
const result = getPlaceType(tags);
|
||||||
|
assert.strictEqual(result, null);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user