Compare commits
84 Commits
323aab8256
...
v1.15.3
| Author | SHA1 | Date | |
|---|---|---|---|
|
3cd1b32af9
|
|||
|
462404b53e
|
|||
|
e3147caa90
|
|||
|
7c11fefdb7
|
|||
|
9af6636971
|
|||
|
8ae7fd1fd8
|
|||
|
cb3fbade39
|
|||
|
6dfd9765b4
|
|||
|
45eb8f087d
|
|||
|
3630fae133
|
|||
|
1116161e93
|
|||
|
88eb0ac0c1
|
|||
|
6da004e199
|
|||
|
8877a9e1c8
|
|||
|
03d6a86569
|
|||
|
5baebbb846
|
|||
|
dca2991754
|
|||
|
aee7f9d181
|
|||
|
56a077cceb
|
|||
|
7e5a034cac
|
|||
|
5892bd0cda
|
|||
|
baaf027900
|
|||
|
beb3d12169
|
|||
|
a5aa396411
|
|||
|
21cb8e6cc2
|
|||
|
12ec7fcbbf
|
|||
|
78d7aeba2c
|
|||
|
3b71531de2
|
|||
|
6ef7549ea9
|
|||
|
9097c63a55
|
|||
|
ec0d5a30f9
|
|||
|
f1779131e8
|
|||
|
37cf47b3dd
|
|||
|
ff68b5addc
|
|||
|
990f3afa88
|
|||
|
b2220b8310
|
|||
|
a8613ab81a
|
|||
|
bcb9b20e85
|
|||
|
466b1d5383
|
|||
|
ea7cb2f895
|
|||
|
7e94f335ac
|
|||
|
066ddb240d
|
|||
|
df336b87ac
|
|||
|
dbf71e366a
|
|||
|
6a83003acb
|
|||
|
bcc7c2a011
|
|||
|
19f04efecb
|
|||
|
c79bbaa41a
|
|||
|
b07640375a
|
|||
|
ffcb8219b0
|
|||
|
e01cb2ce6f
|
|||
|
808c1ee37b
|
|||
|
34bc15cfa9
|
|||
|
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
|
14
.gitea/release-drafter.yml
Normal file
14
.gitea/release-drafter.yml
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
name-template: 'v$RESOLVED_VERSION'
|
||||||
|
tag-template: 'v$RESOLVED_VERSION'
|
||||||
|
version-resolver:
|
||||||
|
major:
|
||||||
|
labels:
|
||||||
|
- 'release/major'
|
||||||
|
minor:
|
||||||
|
labels:
|
||||||
|
- 'release/minor'
|
||||||
|
- 'feature'
|
||||||
|
patch:
|
||||||
|
labels:
|
||||||
|
- 'release/patch'
|
||||||
|
default: patch
|
||||||
@@ -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
|
||||||
13
.gitea/workflows/release_drafter.yml
Normal file
13
.gitea/workflows/release_drafter.yml
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
name: Release Drafter
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
types: [closed]
|
||||||
|
jobs:
|
||||||
|
release_drafter_job:
|
||||||
|
name: Update release notes draft
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Release Drafter
|
||||||
|
uses: https://github.com/raucao/gitea-release-drafter@dev
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
152
app/components/app-menu/about.gjs
Normal file
152
app/components/app-menu/about.gjs
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
import { on } from '@ember/modifier';
|
||||||
|
import Icon from '#components/icon';
|
||||||
|
|
||||||
|
<template>
|
||||||
|
{{! template-lint-disable no-nested-interactive }}
|
||||||
|
<div class="sidebar-header">
|
||||||
|
<button type="button" class="back-btn" {{on "click" @onBack}}>
|
||||||
|
<Icon @name="arrow-left" @size={{20}} @color="#333" />
|
||||||
|
</button>
|
||||||
|
<h2>About</h2>
|
||||||
|
<button type="button" class="close-btn" {{on "click" @onClose}}>
|
||||||
|
<Icon @name="x" @size={{20}} @color="#333" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sidebar-content">
|
||||||
|
<section class="about-section">
|
||||||
|
<p>
|
||||||
|
<strong>Marco</strong>
|
||||||
|
(as in
|
||||||
|
<a
|
||||||
|
href="https://en.wikipedia.org/wiki/Marco_Polo"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener"
|
||||||
|
>Marco Polo</a>) is an unhosted maps application that respects your
|
||||||
|
privacy and choices.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Connect your own
|
||||||
|
<a
|
||||||
|
href="https://remotestorage.io/"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener"
|
||||||
|
>remote storage</a>
|
||||||
|
to sync place bookmarks across apps and devices.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>
|
||||||
|
<Icon @name="gift" @size={{20}} />
|
||||||
|
<span>Open Source</span>
|
||||||
|
</summary>
|
||||||
|
<div class="details-content">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Source</th>
|
||||||
|
<th>License</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<a
|
||||||
|
href="https://gitea.kosmos.org/raucao/marco"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener"
|
||||||
|
>
|
||||||
|
Marco App
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<a
|
||||||
|
href="https://en.wikipedia.org/wiki/GNU_Affero_General_Public_License"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener"
|
||||||
|
>
|
||||||
|
<abbr title="GNU Affero General Public License">AGPL</abbr>
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<a
|
||||||
|
href="https://openstreetmap.org/copyright"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener"
|
||||||
|
>
|
||||||
|
Map Data
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<a
|
||||||
|
href="https://opendatacommons.org/licenses/odbl/"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener"
|
||||||
|
>
|
||||||
|
<abbr
|
||||||
|
title="Open Data Commons Open Database License"
|
||||||
|
>ODbL</abbr>
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<a
|
||||||
|
href="https://github.com/feathericons/feather"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener"
|
||||||
|
>
|
||||||
|
Feather Icons
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<a
|
||||||
|
href="https://en.wikipedia.org/wiki/MIT_License"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener"
|
||||||
|
>
|
||||||
|
<abbr title="MIT License">MIT</abbr>
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
<details>
|
||||||
|
<summary>
|
||||||
|
<Icon @name="heart" @size={{20}} @color="#e5533d" />
|
||||||
|
<span>Contribute</span>
|
||||||
|
</summary>
|
||||||
|
<div class="details-content">
|
||||||
|
<p>
|
||||||
|
<strong>Most impactful:</strong>
|
||||||
|
Add and improve data for points of interest in
|
||||||
|
<a
|
||||||
|
href="https://www.openstreetmap.org"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener"
|
||||||
|
>OpenStreetMap</a>.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong>Most appreciated:</strong>
|
||||||
|
Use this app as much as you can and
|
||||||
|
<a
|
||||||
|
href="https://community.remotestorage.io/t/marco-an-unhosted-maps-app/941"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener"
|
||||||
|
>submit feedback</a>
|
||||||
|
about your experience, problems, feature wishes, etc.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong>Most supportive:</strong>
|
||||||
|
Tell others about this app, on social media, in blog posts,
|
||||||
|
educational videos, etc.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
36
app/components/app-menu/home.gjs
Normal file
36
app/components/app-menu/home.gjs
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { on } from '@ember/modifier';
|
||||||
|
import { fn } from '@ember/helper';
|
||||||
|
import { htmlSafe } from '@ember/template';
|
||||||
|
import Icon from '#components/icon';
|
||||||
|
import iconRounded from '../../icons/icon-rounded.svg?raw';
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="sidebar-header">
|
||||||
|
<h2>
|
||||||
|
<span class="app-logo-icon">
|
||||||
|
{{htmlSafe iconRounded}}
|
||||||
|
</span>
|
||||||
|
Marco
|
||||||
|
</h2>
|
||||||
|
<button type="button" class="close-btn" {{on "click" @onClose}}>
|
||||||
|
<Icon @name="x" @size={{20}} @color="#333" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sidebar-content">
|
||||||
|
<ul class="app-menu">
|
||||||
|
<li>
|
||||||
|
<button type="button" {{on "click" (fn @onNavigate "settings")}}>
|
||||||
|
<Icon @name="settings" @size={{20}} />
|
||||||
|
<span>Settings</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<button type="button" {{on "click" (fn @onNavigate "about")}}>
|
||||||
|
<Icon @name="info" @size={{20}} />
|
||||||
|
<span>About</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
38
app/components/app-menu/index.gjs
Normal file
38
app/components/app-menu/index.gjs
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import Component from '@glimmer/component';
|
||||||
|
import { action } from '@ember/object';
|
||||||
|
import { tracked } from '@glimmer/tracking';
|
||||||
|
import { fn } from '@ember/helper';
|
||||||
|
import eq from 'ember-truth-helpers/helpers/eq';
|
||||||
|
|
||||||
|
import AppMenuHome from './home';
|
||||||
|
import AppMenuSettings from './settings';
|
||||||
|
import AppMenuAbout from './about';
|
||||||
|
|
||||||
|
export default class AppMenu extends Component {
|
||||||
|
@tracked currentView = 'menu'; // 'menu', 'settings', 'about'
|
||||||
|
|
||||||
|
@action
|
||||||
|
setView(view) {
|
||||||
|
this.currentView = view;
|
||||||
|
}
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="sidebar app-menu-pane">
|
||||||
|
{{#if (eq this.currentView "menu")}}
|
||||||
|
<AppMenuHome @onNavigate={{this.setView}} @onClose={{@onClose}} />
|
||||||
|
|
||||||
|
{{else if (eq this.currentView "settings")}}
|
||||||
|
<AppMenuSettings
|
||||||
|
@onBack={{fn this.setView "menu"}}
|
||||||
|
@onClose={{@onClose}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{{else if (eq this.currentView "about")}}
|
||||||
|
<AppMenuAbout
|
||||||
|
@onBack={{fn this.setView "menu"}}
|
||||||
|
@onClose={{@onClose}}
|
||||||
|
/>
|
||||||
|
{{/if}}
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
}
|
||||||
100
app/components/app-menu/settings.gjs
Normal file
100
app/components/app-menu/settings.gjs
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
import Component from '@glimmer/component';
|
||||||
|
import { on } from '@ember/modifier';
|
||||||
|
import { service } from '@ember/service';
|
||||||
|
import { action } from '@ember/object';
|
||||||
|
import Icon from '#components/icon';
|
||||||
|
import eq from 'ember-truth-helpers/helpers/eq';
|
||||||
|
|
||||||
|
export default class AppMenuSettings extends Component {
|
||||||
|
@service settings;
|
||||||
|
|
||||||
|
@action
|
||||||
|
updateApi(event) {
|
||||||
|
this.settings.updateOverpassApi(event.target.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
toggleKinetic(event) {
|
||||||
|
this.settings.updateMapKinetic(event.target.value === 'true');
|
||||||
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
updatePhotonApi(event) {
|
||||||
|
this.settings.updatePhotonApi(event.target.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="sidebar-header">
|
||||||
|
<button type="button" class="back-btn" {{on "click" @onBack}}>
|
||||||
|
<Icon @name="arrow-left" @size={{20}} @color="#333" />
|
||||||
|
</button>
|
||||||
|
<h2>Settings</h2>
|
||||||
|
<button type="button" class="close-btn" {{on "click" @onClose}}>
|
||||||
|
<Icon @name="x" @size={{20}} @color="#333" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sidebar-content">
|
||||||
|
<section class="settings-section">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="map-kinetic">Map Inertia (Kinetic Panning)</label>
|
||||||
|
<select
|
||||||
|
id="map-kinetic"
|
||||||
|
class="form-control"
|
||||||
|
{{on "change" this.toggleKinetic}}
|
||||||
|
>
|
||||||
|
<option
|
||||||
|
value="true"
|
||||||
|
selected={{if this.settings.mapKinetic "selected"}}
|
||||||
|
>
|
||||||
|
On
|
||||||
|
</option>
|
||||||
|
<option
|
||||||
|
value="false"
|
||||||
|
selected={{unless this.settings.mapKinetic "selected"}}
|
||||||
|
>
|
||||||
|
Off
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="overpass-api">Overpass API Provider</label>
|
||||||
|
<select
|
||||||
|
id="overpass-api"
|
||||||
|
class="form-control"
|
||||||
|
{{on "change" this.updateApi}}
|
||||||
|
>
|
||||||
|
{{#each this.settings.overpassApis as |api|}}
|
||||||
|
<option
|
||||||
|
value={{api.url}}
|
||||||
|
selected={{if
|
||||||
|
(eq api.url this.settings.overpassApi)
|
||||||
|
"selected"
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{{api.name}}
|
||||||
|
</option>
|
||||||
|
{{/each}}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="photon-api">Photon API Provider</label>
|
||||||
|
<select
|
||||||
|
id="photon-api"
|
||||||
|
class="form-control"
|
||||||
|
{{on "change" this.updatePhotonApi}}
|
||||||
|
>
|
||||||
|
{{#each this.settings.photonApis as |api|}}
|
||||||
|
<option
|
||||||
|
value={{api.url}}
|
||||||
|
selected={{if (eq api.url this.settings.photonApi) "selected"}}
|
||||||
|
>
|
||||||
|
{{api.name}}
|
||||||
|
</option>
|
||||||
|
{{/each}}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
}
|
||||||
@@ -1,57 +1,10 @@
|
|||||||
import Component from '@glimmer/component';
|
import Component from '@glimmer/component';
|
||||||
import { htmlSafe } from '@ember/template';
|
import { htmlSafe } from '@ember/template';
|
||||||
|
import { getIcon } from '../utils/icons';
|
||||||
import arrowLeft from 'feather-icons/dist/icons/arrow-left.svg?raw';
|
|
||||||
import activity from 'feather-icons/dist/icons/activity.svg?raw';
|
|
||||||
import bookmark from 'feather-icons/dist/icons/bookmark.svg?raw';
|
|
||||||
import clock from 'feather-icons/dist/icons/clock.svg?raw';
|
|
||||||
import edit from 'feather-icons/dist/icons/edit.svg?raw';
|
|
||||||
import globe from 'feather-icons/dist/icons/globe.svg?raw';
|
|
||||||
import home from 'feather-icons/dist/icons/home.svg?raw';
|
|
||||||
import logIn from 'feather-icons/dist/icons/log-in.svg?raw';
|
|
||||||
import logOut from 'feather-icons/dist/icons/log-out.svg?raw';
|
|
||||||
import map from 'feather-icons/dist/icons/map.svg?raw';
|
|
||||||
import mapPin from 'feather-icons/dist/icons/map-pin.svg?raw';
|
|
||||||
import menu from 'feather-icons/dist/icons/menu.svg?raw';
|
|
||||||
import navigation from 'feather-icons/dist/icons/navigation.svg?raw';
|
|
||||||
import phone from 'feather-icons/dist/icons/phone.svg?raw';
|
|
||||||
import plus from 'feather-icons/dist/icons/plus.svg?raw';
|
|
||||||
import server from 'feather-icons/dist/icons/server.svg?raw';
|
|
||||||
import search from 'feather-icons/dist/icons/search.svg?raw';
|
|
||||||
import settings from 'feather-icons/dist/icons/settings.svg?raw';
|
|
||||||
import target from 'feather-icons/dist/icons/target.svg?raw';
|
|
||||||
import user from 'feather-icons/dist/icons/user.svg?raw';
|
|
||||||
import x from 'feather-icons/dist/icons/x.svg?raw';
|
|
||||||
import zap from 'feather-icons/dist/icons/zap.svg?raw';
|
|
||||||
|
|
||||||
const ICONS = {
|
|
||||||
'arrow-left': arrowLeft,
|
|
||||||
activity,
|
|
||||||
bookmark,
|
|
||||||
clock,
|
|
||||||
edit,
|
|
||||||
globe,
|
|
||||||
home,
|
|
||||||
'log-in': logIn,
|
|
||||||
'log-out': logOut,
|
|
||||||
map,
|
|
||||||
'map-pin': mapPin,
|
|
||||||
menu,
|
|
||||||
navigation,
|
|
||||||
phone,
|
|
||||||
plus,
|
|
||||||
server,
|
|
||||||
search,
|
|
||||||
settings,
|
|
||||||
target,
|
|
||||||
user,
|
|
||||||
x,
|
|
||||||
zap,
|
|
||||||
};
|
|
||||||
|
|
||||||
export default class IconComponent extends Component {
|
export default class IconComponent extends Component {
|
||||||
get svg() {
|
get svg() {
|
||||||
return ICONS[this.args.name];
|
return getIcon(this.args.name);
|
||||||
}
|
}
|
||||||
|
|
||||||
get size() {
|
get size() {
|
||||||
@@ -63,7 +16,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 +27,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,11 +42,48 @@ 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({
|
|
||||||
source: this.bookmarkSource,
|
const bookmarkStyleFunction = (feature) => {
|
||||||
style: [
|
const originalPlace = feature.get('originalPlace');
|
||||||
|
let color =
|
||||||
|
getComputedStyle(document.documentElement)
|
||||||
|
.getPropertyValue('--default-list-color')
|
||||||
|
.trim() || '#000000'; // Fallback to black if variable is missing to make error obvious
|
||||||
|
|
||||||
|
if (
|
||||||
|
originalPlace &&
|
||||||
|
originalPlace._listIds &&
|
||||||
|
originalPlace._listIds.length > 0
|
||||||
|
) {
|
||||||
|
// Find the first list color
|
||||||
|
// We need access to storage.lists.
|
||||||
|
// Since this is inside setupMap, 'this' refers to the component instance.
|
||||||
|
const firstListId = originalPlace._listIds[0];
|
||||||
|
const list = this.storage.lists.find((l) => l.id === firstListId);
|
||||||
|
if (list && list.color) {
|
||||||
|
color = list.color;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
new Style({
|
new Style({
|
||||||
image: new Circle({
|
image: new Circle({
|
||||||
radius: 10,
|
radius: 10,
|
||||||
@@ -55,14 +94,19 @@ export default class MapComponent extends Component {
|
|||||||
new Style({
|
new Style({
|
||||||
image: new Circle({
|
image: new Circle({
|
||||||
radius: 9,
|
radius: 9,
|
||||||
fill: new Fill({ color: '#ffcc33' }), // Gold/Yellow
|
fill: new Fill({ color: color }),
|
||||||
stroke: new Stroke({
|
stroke: new Stroke({
|
||||||
color: '#fff',
|
color: '#fff',
|
||||||
width: 2,
|
width: 2,
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
],
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
const bookmarkLayer = new VectorLayer({
|
||||||
|
source: this.bookmarkSource,
|
||||||
|
style: bookmarkStyleFunction,
|
||||||
zIndex: 10, // Ensure it sits above the map tiles
|
zIndex: 10, // Ensure it sits above the map tiles
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -99,7 +143,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,
|
||||||
@@ -423,9 +467,15 @@ export default class MapComponent extends Component {
|
|||||||
// Track the selected place from the UI Service (Router -> Map)
|
// Track the selected place from the UI Service (Router -> Map)
|
||||||
updateSelectedPin = modifier(() => {
|
updateSelectedPin = modifier(() => {
|
||||||
const selected = this.mapUi.selectedPlace;
|
const selected = this.mapUi.selectedPlace;
|
||||||
|
const options = this.mapUi.selectionOptions || {};
|
||||||
|
|
||||||
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 +486,27 @@ 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 (options.preventZoom) {
|
||||||
|
// If we are preventing zoom (e.g. user clicked a bookmark), we rely on visibility check.
|
||||||
|
// This avoids unnecessary panning if the place is already visible.
|
||||||
|
this.handlePinVisibility(coords, { maintainZoom: true });
|
||||||
|
} else 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,9 +514,73 @@ export default class MapComponent extends Component {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
handlePinVisibility(coords) {
|
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 sidebarWidthVar = getComputedStyle(document.documentElement)
|
||||||
|
.getPropertyValue('--sidebar-width')
|
||||||
|
.trim();
|
||||||
|
const sidebarWidth = parseInt(sidebarWidthVar, 10) || 360;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentZoom = view.getZoom();
|
||||||
|
|
||||||
|
view.fit(extent, {
|
||||||
|
padding: padding,
|
||||||
|
duration: 1000,
|
||||||
|
easing: (t) => t * (2 - t),
|
||||||
|
maxZoom: Math.max(currentZoom, 18),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
handlePinVisibility(coords, options = {}) {
|
||||||
if (!this.mapInstance) return;
|
if (!this.mapInstance) return;
|
||||||
|
|
||||||
|
const view = this.mapInstance.getView();
|
||||||
|
const currentZoom = view.getZoom();
|
||||||
|
|
||||||
|
// If too far out (e.g. world view), zoom in to neighborhood level (16)
|
||||||
|
// UNLESS we want to maintain the current zoom
|
||||||
|
if (!options.maintainZoom && currentZoom < 16) {
|
||||||
|
this.animateToSmartCenter(coords, 16);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const pixel = this.mapInstance.getPixelFromCoordinate(coords);
|
const pixel = this.mapInstance.getPixelFromCoordinate(coords);
|
||||||
const size = this.mapInstance.getSize();
|
const size = this.mapInstance.getSize();
|
||||||
|
|
||||||
@@ -459,18 +593,27 @@ export default class MapComponent extends Component {
|
|||||||
pixel[1] > size[1];
|
pixel[1] > size[1];
|
||||||
|
|
||||||
if (isOffScreen) {
|
if (isOffScreen) {
|
||||||
this.animateToSmartCenter(coords);
|
// If off-screen, center it smartly (considering sidebar/bottom sheet)
|
||||||
|
// Pass maintainZoom to prevent zoom reset if desired
|
||||||
|
const zoom = options.maintainZoom ? null : 16;
|
||||||
|
this.animateToSmartCenter(coords, zoom);
|
||||||
} else {
|
} else {
|
||||||
|
// If on-screen, only pan if obscured by UI
|
||||||
this.panIfObscured(coords);
|
this.panIfObscured(coords);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
animateToSmartCenter(coords) {
|
animateToSmartCenter(coords, zoom = null) {
|
||||||
if (!this.mapInstance) return;
|
if (!this.mapInstance) return;
|
||||||
|
|
||||||
const size = this.mapInstance.getSize();
|
const size = this.mapInstance.getSize();
|
||||||
const view = this.mapInstance.getView();
|
const view = this.mapInstance.getView();
|
||||||
const resolution = view.getResolution();
|
let resolution = view.getResolution();
|
||||||
|
|
||||||
|
if (zoom !== null) {
|
||||||
|
resolution = view.getResolutionForZoom(zoom);
|
||||||
|
}
|
||||||
|
|
||||||
let targetCenter = coords;
|
let targetCenter = coords;
|
||||||
|
|
||||||
// Check if mobile (width <= 768px matches CSS)
|
// Check if mobile (width <= 768px matches CSS)
|
||||||
@@ -491,45 +634,113 @@ export default class MapComponent extends Component {
|
|||||||
// To move the camera South (Lower Y), we subtract.
|
// To move the camera South (Lower Y), we subtract.
|
||||||
targetCenter = [coords[0], coords[1] - offsetMapUnits];
|
targetCenter = [coords[0], coords[1] - offsetMapUnits];
|
||||||
}
|
}
|
||||||
|
// Desktop: Check if sidebar is open
|
||||||
|
else if (this.args.isSidebarOpen) {
|
||||||
|
const sidebarWidthVar = getComputedStyle(document.documentElement)
|
||||||
|
.getPropertyValue('--sidebar-width')
|
||||||
|
.trim();
|
||||||
|
const sidebarWidth = parseInt(sidebarWidthVar, 10) || 360;
|
||||||
|
|
||||||
view.animate({
|
// We want the pin to be in the center of the remaining space.
|
||||||
|
// Remaining space starts at x = sidebarWidth.
|
||||||
|
// Center of remaining space = sidebarWidth + (totalWidth - sidebarWidth) / 2
|
||||||
|
// = sidebarWidth/2 + totalWidth/2
|
||||||
|
// Map Center is totalWidth/2
|
||||||
|
// Offset = sidebarWidth/2 (to the right)
|
||||||
|
|
||||||
|
const offsetPixels = sidebarWidth / 2;
|
||||||
|
const offsetMapUnits = offsetPixels * resolution;
|
||||||
|
|
||||||
|
// We want pin at center + offset.
|
||||||
|
// So map center must be pin - offset.
|
||||||
|
// X increases to the right.
|
||||||
|
targetCenter = [coords[0] - offsetMapUnits, coords[1]];
|
||||||
|
}
|
||||||
|
|
||||||
|
const animationOptions = {
|
||||||
center: targetCenter,
|
center: targetCenter,
|
||||||
duration: 1000,
|
duration: 1000,
|
||||||
easing: (t) => t * (2 - t), // Ease-out
|
easing: (t) => t * (2 - t), // Ease-out
|
||||||
});
|
};
|
||||||
|
|
||||||
|
if (zoom !== null) {
|
||||||
|
animationOptions.zoom = zoom;
|
||||||
|
}
|
||||||
|
|
||||||
|
view.animate(animationOptions);
|
||||||
}
|
}
|
||||||
|
|
||||||
panIfObscured(coords) {
|
panIfObscured(coords) {
|
||||||
if (!this.mapInstance) return;
|
if (!this.mapInstance) return;
|
||||||
|
|
||||||
const size = this.mapInstance.getSize();
|
const size = this.mapInstance.getSize();
|
||||||
// Check if mobile (width <= 768px matches CSS)
|
|
||||||
if (size[0] > 768) return;
|
|
||||||
|
|
||||||
const pixel = this.mapInstance.getPixelFromCoordinate(coords);
|
const pixel = this.mapInstance.getPixelFromCoordinate(coords);
|
||||||
if (!pixel) return;
|
if (!pixel) return;
|
||||||
|
|
||||||
const height = size[1];
|
const view = this.mapInstance.getView();
|
||||||
|
const center = view.getCenter();
|
||||||
|
const resolution = view.getResolution();
|
||||||
|
|
||||||
// Sidebar covers the bottom 50%
|
// Default targets (current position)
|
||||||
const splitPoint = height / 2;
|
let targetPixelX = pixel[0];
|
||||||
|
let targetPixelY = pixel[1];
|
||||||
|
let needsPan = false;
|
||||||
|
|
||||||
// If the pin is in the bottom half (y > splitPoint), it is obscured
|
// 1. Mobile Bottom Sheet Logic (Screen <= 768px)
|
||||||
if (pixel[1] > splitPoint) {
|
if (size[0] <= 768) {
|
||||||
// Target position: Center of top half = height * 0.25
|
const height = size[1];
|
||||||
const targetY = height * 0.25;
|
const splitPoint = height / 2;
|
||||||
const deltaY = pixel[1] - targetY;
|
|
||||||
|
|
||||||
const view = this.mapInstance.getView();
|
// If in bottom half
|
||||||
const center = view.getCenter();
|
if (pixel[1] > splitPoint) {
|
||||||
const resolution = view.getResolution();
|
targetPixelY = height * 0.25; // Target: Center of top half
|
||||||
|
needsPan = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 2. Desktop Sidebar Logic (Screen > 768px + Sidebar Open)
|
||||||
|
else if (this.args.isSidebarOpen) {
|
||||||
|
const sidebarWidthVar = getComputedStyle(document.documentElement)
|
||||||
|
.getPropertyValue('--sidebar-width')
|
||||||
|
.trim();
|
||||||
|
const sidebarWidth = parseInt(sidebarWidthVar, 10) || 360;
|
||||||
|
|
||||||
// Move the map center SOUTH (decrease Y) to move the pin UP (decrease pixel Y)
|
// If under sidebar
|
||||||
const deltaMapUnits = deltaY * resolution;
|
if (pixel[0] < sidebarWidth) {
|
||||||
const newCenter = [center[0], center[1] - deltaMapUnits];
|
const visibleWidth = size[0] - sidebarWidth;
|
||||||
|
targetPixelX = sidebarWidth + visibleWidth / 2; // Target: Center of visible area
|
||||||
|
needsPan = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Header Logic (Any screen size)
|
||||||
|
// Check if the (potentially new) target Y is under the header
|
||||||
|
const headerHeight = 60;
|
||||||
|
const minTopDistance = headerHeight + 20; // 80px
|
||||||
|
|
||||||
|
if (targetPixelY < minTopDistance) {
|
||||||
|
targetPixelY = minTopDistance + 30; // Move it to ~110px, clear of header
|
||||||
|
needsPan = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (needsPan) {
|
||||||
|
const deltaPixelX = pixel[0] - targetPixelX;
|
||||||
|
const deltaPixelY = pixel[1] - targetPixelY;
|
||||||
|
|
||||||
|
// X: Camera moves same direction as we want the world to move? No.
|
||||||
|
// If we want pin to move RIGHT (pixel increases), Camera must move LEFT (X decreases).
|
||||||
|
// deltaPixelX = current - target. If current < target (want move right), delta is negative.
|
||||||
|
// center + negative = decrease. Correct.
|
||||||
|
const newCenterX = center[0] + deltaPixelX * resolution;
|
||||||
|
|
||||||
|
// Y: Camera moves opposite direction to world relative to pixel coords.
|
||||||
|
// Pixel Y increases DOWN. Map Y increases UP.
|
||||||
|
// If we want pin to move DOWN (pixel increases), Camera must move UP (Y increases).
|
||||||
|
// deltaPixelY = current - target. If current < target (want move down), delta is negative.
|
||||||
|
// center - negative = increase. Correct.
|
||||||
|
const newCenterY = center[1] - deltaPixelY * resolution;
|
||||||
|
|
||||||
view.animate({
|
view.animate({
|
||||||
center: newCenter,
|
center: [newCenterX, newCenterY],
|
||||||
duration: 500,
|
duration: 500,
|
||||||
easing: (t) => t * (2 - t), // Ease-out
|
easing: (t) => t * (2 - t), // Ease-out
|
||||||
});
|
});
|
||||||
@@ -760,6 +971,7 @@ export default class MapComponent extends Component {
|
|||||||
'Clicked bookmark while sidebar open (switching):',
|
'Clicked bookmark while sidebar open (switching):',
|
||||||
clickedBookmark
|
clickedBookmark
|
||||||
);
|
);
|
||||||
|
this.mapUi.preventNextZoom = true;
|
||||||
this.router.transitionTo('place', clickedBookmark);
|
this.router.transitionTo('place', clickedBookmark);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -774,6 +986,7 @@ export default class MapComponent extends Component {
|
|||||||
// Normal behavior (sidebar is closed)
|
// Normal behavior (sidebar is closed)
|
||||||
if (clickedBookmark) {
|
if (clickedBookmark) {
|
||||||
console.debug('Clicked bookmark:', clickedBookmark);
|
console.debug('Clicked bookmark:', clickedBookmark);
|
||||||
|
this.mapUi.preventNextZoom = true;
|
||||||
this.router.transitionTo('place', clickedBookmark);
|
this.router.transitionTo('place', clickedBookmark);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,21 +1,39 @@
|
|||||||
import Component from '@glimmer/component';
|
import Component from '@glimmer/component';
|
||||||
import { fn } from '@ember/helper';
|
import { service } from '@ember/service';
|
||||||
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, getPlaceType } from '../utils/osm';
|
import { getLocalizedName, getPlaceType } from '../utils/osm';
|
||||||
|
import { mapToStorageSchema } from '../utils/place-mapping';
|
||||||
|
import { getSocialInfo } from '../utils/social-links';
|
||||||
import Icon from '../components/icon';
|
import Icon from '../components/icon';
|
||||||
import PlaceEditForm from './place-edit-form';
|
import PlaceEditForm from './place-edit-form';
|
||||||
|
import PlaceListsManager from './place-lists-manager';
|
||||||
|
|
||||||
import { tracked } from '@glimmer/tracking';
|
import { tracked } from '@glimmer/tracking';
|
||||||
import { action } from '@ember/object';
|
import { action } from '@ember/object';
|
||||||
|
|
||||||
export default class PlaceDetails extends Component {
|
export default class PlaceDetails extends Component {
|
||||||
|
@service storage;
|
||||||
@tracked isEditing = false;
|
@tracked isEditing = false;
|
||||||
|
@tracked showLists = false;
|
||||||
|
|
||||||
|
get isSaved() {
|
||||||
|
return this.storage.isPlaceSaved(this.place.id || this.place.osmId);
|
||||||
|
}
|
||||||
|
|
||||||
get place() {
|
get place() {
|
||||||
return this.args.place || {};
|
return this.args.place || {};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get saveablePlace() {
|
||||||
|
if (this.place.createdAt) {
|
||||||
|
return this.place;
|
||||||
|
}
|
||||||
|
|
||||||
|
return mapToStorageSchema(this.place);
|
||||||
|
}
|
||||||
|
|
||||||
get tags() {
|
get tags() {
|
||||||
return this.place.osmTags || {};
|
return this.place.osmTags || {};
|
||||||
}
|
}
|
||||||
@@ -26,7 +44,7 @@ export default class PlaceDetails extends Component {
|
|||||||
|
|
||||||
@action
|
@action
|
||||||
startEditing() {
|
startEditing() {
|
||||||
if (!this.place.createdAt) return; // Only allow editing saved places
|
if (!this.isSaved) return; // Only allow editing saved places
|
||||||
this.isEditing = true;
|
this.isEditing = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -35,6 +53,21 @@ export default class PlaceDetails extends Component {
|
|||||||
this.isEditing = false;
|
this.isEditing = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
toggleLists(event) {
|
||||||
|
// Prevent this click from propagating to the document listener
|
||||||
|
// which handles the "click outside" logic.
|
||||||
|
if (event) {
|
||||||
|
event.stopPropagation();
|
||||||
|
}
|
||||||
|
this.showLists = !this.showLists;
|
||||||
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
closeLists() {
|
||||||
|
this.showLists = false;
|
||||||
|
}
|
||||||
|
|
||||||
@action
|
@action
|
||||||
async saveChanges(changes) {
|
async saveChanges(changes) {
|
||||||
if (this.args.onSave) {
|
if (this.args.onSave) {
|
||||||
@@ -83,7 +116,7 @@ export default class PlaceDetails extends Component {
|
|||||||
}
|
}
|
||||||
parts.push(city);
|
parts.push(city);
|
||||||
}
|
}
|
||||||
|
|
||||||
// State + Country (if not already covered)
|
// State + Country (if not already covered)
|
||||||
const state = get('addr:state', 'state');
|
const state = get('addr:state', 'state');
|
||||||
const country = get('addr:country', 'country');
|
const country = get('addr:country', 'country');
|
||||||
@@ -95,21 +128,70 @@ export default class PlaceDetails extends Component {
|
|||||||
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 === 'email') {
|
||||||
|
return htmlSafe(
|
||||||
|
parts.map((p) => `<a href="mailto:${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 email() {
|
||||||
|
const val = this.tags.email || this.tags['contact:email'];
|
||||||
|
return this.formatMultiLine(val, 'email');
|
||||||
}
|
}
|
||||||
|
|
||||||
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() {
|
||||||
@@ -120,8 +202,21 @@ export default class PlaceDetails extends Component {
|
|||||||
.join(', ');
|
.join(', ');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get facebook() {
|
||||||
|
return getSocialInfo(this.tags, 'facebook');
|
||||||
|
}
|
||||||
|
|
||||||
|
get instagram() {
|
||||||
|
return getSocialInfo(this.tags, 'instagram');
|
||||||
|
}
|
||||||
|
|
||||||
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() {
|
||||||
@@ -151,7 +246,7 @@ export default class PlaceDetails extends Component {
|
|||||||
if (!id) return null;
|
if (!id) return null;
|
||||||
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() {
|
get showDescription() {
|
||||||
// If it's a Photon result, the description IS the address.
|
// If it's a Photon result, the description IS the address.
|
||||||
// Since we are showing the address in the meta section (bottom),
|
// Since we are showing the address in the meta section (bottom),
|
||||||
@@ -183,30 +278,36 @@ export default class PlaceDetails extends Component {
|
|||||||
{{/if}}
|
{{/if}}
|
||||||
|
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<button
|
<div class="save-button-wrapper">
|
||||||
type="button"
|
<button
|
||||||
class={{if
|
type="button"
|
||||||
this.place.createdAt
|
class={{if this.isSaved "btn btn-secondary" "btn btn-outline"}}
|
||||||
"btn btn-secondary"
|
{{on "click" this.toggleLists}}
|
||||||
"btn btn-outline"
|
>
|
||||||
}}
|
<Icon
|
||||||
{{on "click" (fn @onToggleSave this.place)}}
|
@name="bookmark"
|
||||||
>
|
@color={{if this.isSaved "currentColor" "var(--link-color)"}}
|
||||||
<Icon
|
/>
|
||||||
@name="bookmark"
|
{{if this.isSaved "Saved" "Save"}}
|
||||||
@color={{if this.place.createdAt "currentColor" "#007bff"}}
|
</button>
|
||||||
/>
|
|
||||||
{{if this.place.createdAt "Saved" "Save"}}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{{#if this.place.createdAt}}
|
{{#if this.showLists}}
|
||||||
|
<PlaceListsManager
|
||||||
|
@place={{this.saveablePlace}}
|
||||||
|
@onClose={{this.closeLists}}
|
||||||
|
@isSaved={{this.isSaved}}
|
||||||
|
/>
|
||||||
|
{{/if}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{#if this.isSaved}}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="btn btn-outline"
|
class="btn btn-outline"
|
||||||
title="Edit"
|
title="Edit"
|
||||||
{{on "click" this.startEditing}}
|
{{on "click" this.startEditing}}
|
||||||
>
|
>
|
||||||
<Icon @name="edit" @color="#007bff" />
|
<Icon @name="edit" @color="var(--link-color)" />
|
||||||
Edit
|
Edit
|
||||||
</button>
|
</button>
|
||||||
{{/if}}
|
{{/if}}
|
||||||
@@ -215,7 +316,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>
|
||||||
@@ -224,36 +325,81 @@ 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}}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
{{/if}}
|
||||||
|
|
||||||
|
{{#if this.email}}
|
||||||
|
<p class="content-with-icon">
|
||||||
|
<Icon @name="mail" @title="Email" />
|
||||||
|
<span>
|
||||||
|
{{this.email}}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
{{/if}}
|
||||||
|
|
||||||
|
{{#if this.facebook}}
|
||||||
|
<p class="content-with-icon">
|
||||||
|
<Icon @name="facebook" @title="Facebook" />
|
||||||
|
<span>
|
||||||
|
<a
|
||||||
|
href={{this.facebook.url}}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
>{{this.websiteDomain}}</a></span>
|
>
|
||||||
|
{{this.facebook.username}}
|
||||||
|
</a>
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
{{/if}}
|
||||||
|
|
||||||
|
{{#if this.instagram}}
|
||||||
|
<p class="content-with-icon">
|
||||||
|
<Icon @name="instagram" @title="Instagram" />
|
||||||
|
<span>
|
||||||
|
<a
|
||||||
|
href={{this.instagram.url}}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
{{this.instagram.username}}
|
||||||
|
</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"
|
||||||
|
|||||||
135
app/components/place-lists-manager.gjs
Normal file
135
app/components/place-lists-manager.gjs
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
import Component from '@glimmer/component';
|
||||||
|
import { service } from '@ember/service';
|
||||||
|
import { action } from '@ember/object';
|
||||||
|
import { tracked } from '@glimmer/tracking';
|
||||||
|
import { on } from '@ember/modifier';
|
||||||
|
import { fn } from '@ember/helper';
|
||||||
|
import { htmlSafe } from '@ember/template';
|
||||||
|
import onClickOutside from '../modifiers/on-click-outside';
|
||||||
|
|
||||||
|
export default class PlaceListsManager extends Component {
|
||||||
|
@service storage;
|
||||||
|
@service router;
|
||||||
|
@tracked _forceClear = false;
|
||||||
|
|
||||||
|
get isSaved() {
|
||||||
|
return this.args.isSaved;
|
||||||
|
}
|
||||||
|
|
||||||
|
get placeListIds() {
|
||||||
|
if (this._forceClear) return [];
|
||||||
|
return this.args.place._listIds || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
styleFor(color) {
|
||||||
|
return htmlSafe(`background-color: ${color}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
isInList(list) {
|
||||||
|
if (!this.placeListIds) return false;
|
||||||
|
return this.placeListIds.includes(list.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
async toggleSaved() {
|
||||||
|
if (this.isSaved) {
|
||||||
|
const { osmId, osmType } = this.args.place;
|
||||||
|
|
||||||
|
await this.storage.removePlace(this.args.place);
|
||||||
|
|
||||||
|
// Clean up the local object reference immediately to prevent UI flicker
|
||||||
|
// or stale state if the transition is delayed/cancelled.
|
||||||
|
if (this.args.place) {
|
||||||
|
this.args.place.id = null;
|
||||||
|
this.args.place.createdAt = null;
|
||||||
|
this.args.place._listIds = [];
|
||||||
|
this._forceClear = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transition immediately to the canonical state
|
||||||
|
if (osmId && osmType) {
|
||||||
|
// Create a transient copy that looks like a fresh OSM result
|
||||||
|
const rawPlace = { ...this.args.place };
|
||||||
|
delete rawPlace.id;
|
||||||
|
delete rawPlace.createdAt;
|
||||||
|
delete rawPlace._listIds;
|
||||||
|
|
||||||
|
// Transition to the place route using the raw object
|
||||||
|
// This updates the URL to 'osm:...' and renders immediately
|
||||||
|
this.router.transitionTo('place', rawPlace);
|
||||||
|
} else {
|
||||||
|
// Custom place deleted -> go home
|
||||||
|
this.router.transitionTo('index');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.args.onClose) this.args.onClose();
|
||||||
|
} else {
|
||||||
|
await this.storage.storePlace(this.args.place);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
async toggleList(list) {
|
||||||
|
const isMember = this.placeListIds.includes(list.id);
|
||||||
|
const shouldAdd = !isMember;
|
||||||
|
|
||||||
|
if (shouldAdd && !this.isSaved) {
|
||||||
|
// Auto-save if adding to list
|
||||||
|
await this.storage.storePlace(this.args.place);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Toggle membership
|
||||||
|
// We must pass the SAVED place (with ID) to the toggle function
|
||||||
|
// If we just saved it above, the args.place might still be the old object reference unless storage updates it in-place?
|
||||||
|
// StorageService.storePlace returns the new object.
|
||||||
|
// But togglePlaceList handles saving internally if ID is missing.
|
||||||
|
|
||||||
|
// Let's rely on storage.togglePlaceList to handle the "save if needed" part.
|
||||||
|
await this.storage.togglePlaceList(this.args.place, list.id, shouldAdd);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
alert('Failed to update list: ' + e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="place-lists-manager" {{onClickOutside @onClose}}>
|
||||||
|
<div class="list-item master-toggle">
|
||||||
|
<label>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={{this.isSaved}}
|
||||||
|
{{on "change" this.toggleSaved}}
|
||||||
|
/>
|
||||||
|
<span class="list-color"></span>
|
||||||
|
<span class="list-name">Saved places</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="divider"></div>
|
||||||
|
|
||||||
|
<div class="lists-container">
|
||||||
|
{{#each this.storage.lists as |list|}}
|
||||||
|
<div class="list-item">
|
||||||
|
<label>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={{this.isInList list}}
|
||||||
|
{{on "change" (fn this.toggleList list)}}
|
||||||
|
disabled={{unless this.isSaved true}}
|
||||||
|
/>
|
||||||
|
{{! template-lint-disable no-inline-styles }}
|
||||||
|
<span
|
||||||
|
class="list-color"
|
||||||
|
style={{this.styleFor list.color}}
|
||||||
|
></span>
|
||||||
|
<span class="list-name">{{list.title}}</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{{/each}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
}
|
||||||
@@ -51,40 +51,39 @@ export default class PlacesSidebar extends Component {
|
|||||||
if (!place) return;
|
if (!place) return;
|
||||||
|
|
||||||
if (place.createdAt) {
|
if (place.createdAt) {
|
||||||
if (confirm(`Delete "${place.title}"?`)) {
|
// Direct delete without confirmation
|
||||||
try {
|
try {
|
||||||
await this.storage.removePlace(place);
|
await this.storage.removePlace(place);
|
||||||
console.debug('Place deleted:', place.title);
|
console.debug('Place deleted:', place.title);
|
||||||
|
|
||||||
// Notify parent to refresh map bookmarks
|
// Notify parent to refresh map bookmarks
|
||||||
if (this.args.onBookmarkChange) {
|
if (this.args.onBookmarkChange) {
|
||||||
this.args.onBookmarkChange();
|
this.args.onBookmarkChange();
|
||||||
}
|
|
||||||
|
|
||||||
if (this.args.onUpdate) {
|
|
||||||
// Reconstruct the "original" place without ID/Geohash/CreatedAt
|
|
||||||
const freshPlace = {
|
|
||||||
...place,
|
|
||||||
id: undefined,
|
|
||||||
geohash: undefined,
|
|
||||||
createdAt: undefined,
|
|
||||||
};
|
|
||||||
this.args.onUpdate(freshPlace);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Also fire onSelect if it exists (for list view)
|
|
||||||
if (this.args.onSelect) {
|
|
||||||
this.args.onSelect(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Close sidebar after delete
|
|
||||||
if (this.args.onClose) {
|
|
||||||
this.args.onClose();
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Failed to delete:', e);
|
|
||||||
alert('Failed to delete: ' + e.message);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (this.args.onUpdate) {
|
||||||
|
// Reconstruct the "original" place without ID/Geohash/CreatedAt
|
||||||
|
const freshPlace = {
|
||||||
|
...place,
|
||||||
|
id: undefined,
|
||||||
|
geohash: undefined,
|
||||||
|
createdAt: undefined,
|
||||||
|
};
|
||||||
|
this.args.onUpdate(freshPlace);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also fire onSelect if it exists (for list view)
|
||||||
|
if (this.args.onSelect) {
|
||||||
|
this.args.onSelect(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close sidebar after delete
|
||||||
|
if (this.args.onClose) {
|
||||||
|
this.args.onClose();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to delete:', e);
|
||||||
|
alert('Failed to delete: ' + e.message);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// It's a fresh POI -> Save it
|
// It's a fresh POI -> Save it
|
||||||
@@ -145,6 +144,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">
|
||||||
@@ -155,7 +159,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"
|
||||||
@@ -190,6 +199,8 @@ export default class PlacesSidebar extends Component {
|
|||||||
<div class="place-type">
|
<div class="place-type">
|
||||||
{{#if (eq place.source "osm")}}
|
{{#if (eq place.source "osm")}}
|
||||||
{{humanizeOsmTag place.type}}
|
{{humanizeOsmTag place.type}}
|
||||||
|
{{else if (eq place.source "photon")}}
|
||||||
|
{{place.description}}
|
||||||
{{else}}
|
{{else}}
|
||||||
{{#if place.osmTags}}
|
{{#if place.osmTags}}
|
||||||
{{humanizeOsmTag (getPlaceType place.osmTags)}}
|
{{humanizeOsmTag (getPlaceType place.osmTags)}}
|
||||||
@@ -203,7 +214,11 @@ export default class PlacesSidebar extends Component {
|
|||||||
{{/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
|
||||||
@@ -211,7 +226,7 @@ export default class PlacesSidebar extends Component {
|
|||||||
class="btn btn-outline create-place"
|
class="btn btn-outline create-place"
|
||||||
{{on "click" this.createNewPlace}}
|
{{on "click" this.createNewPlace}}
|
||||||
>
|
>
|
||||||
<Icon @name="plus" @size={{18}} @color="#007bff" />
|
<Icon @name="plus" @size={{18}} @color="var(--link-color)" />
|
||||||
Create new place
|
Create new place
|
||||||
</button>
|
</button>
|
||||||
{{/if}}
|
{{/if}}
|
||||||
|
|||||||
@@ -181,9 +181,11 @@ export default class SearchBoxComponent extends Component {
|
|||||||
<div class="result-info">
|
<div class="result-info">
|
||||||
<span class="result-title">{{result.title}}</span>
|
<span class="result-title">{{result.title}}</span>
|
||||||
{{#if (eq result.source "osm")}}
|
{{#if (eq result.source "osm")}}
|
||||||
<span class="result-desc">{{humanizeOsmTag result.type}}</span>
|
<span class="result-desc">{{humanizeOsmTag
|
||||||
|
result.type
|
||||||
|
}}</span>
|
||||||
{{else}}
|
{{else}}
|
||||||
{{#if result.description}}
|
{{#if result.description}}
|
||||||
<span class="result-desc">{{result.description}}</span>
|
<span class="result-desc">{{result.description}}</span>
|
||||||
{{/if}}
|
{{/if}}
|
||||||
{{/if}}
|
{{/if}}
|
||||||
|
|||||||
@@ -1,126 +0,0 @@
|
|||||||
import Component from '@glimmer/component';
|
|
||||||
import { on } from '@ember/modifier';
|
|
||||||
import { service } from '@ember/service';
|
|
||||||
import { action } from '@ember/object';
|
|
||||||
import Icon from '#components/icon';
|
|
||||||
import eq from 'ember-truth-helpers/helpers/eq';
|
|
||||||
import not from 'ember-truth-helpers/helpers/not';
|
|
||||||
|
|
||||||
export default class SettingsPane extends Component {
|
|
||||||
@service settings;
|
|
||||||
|
|
||||||
@action
|
|
||||||
updateApi(event) {
|
|
||||||
this.settings.updateOverpassApi(event.target.value);
|
|
||||||
}
|
|
||||||
|
|
||||||
@action
|
|
||||||
toggleKinetic(event) {
|
|
||||||
this.settings.updateMapKinetic(event.target.value === 'true');
|
|
||||||
}
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="sidebar settings-pane">
|
|
||||||
<div class="sidebar-header">
|
|
||||||
<h2>Marco</h2>
|
|
||||||
<button type="button" class="close-btn" {{on "click" @onClose}}>
|
|
||||||
<Icon @name="x" @size={{20}} @color="#333" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="sidebar-content">
|
|
||||||
<section class="settings-section">
|
|
||||||
<h3>Settings</h3>
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="map-kinetic">Map Inertia (Kinetic Panning)</label>
|
|
||||||
<select
|
|
||||||
id="map-kinetic"
|
|
||||||
class="form-control"
|
|
||||||
{{on "change" this.toggleKinetic}}
|
|
||||||
>
|
|
||||||
<option
|
|
||||||
value="true"
|
|
||||||
selected={{if this.settings.mapKinetic "selected"}}
|
|
||||||
>
|
|
||||||
On
|
|
||||||
</option>
|
|
||||||
<option
|
|
||||||
value="false"
|
|
||||||
selected={{unless this.settings.mapKinetic "selected"}}
|
|
||||||
>
|
|
||||||
Off
|
|
||||||
</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="overpass-api">Overpass API Provider</label>
|
|
||||||
<select
|
|
||||||
id="overpass-api"
|
|
||||||
class="form-control"
|
|
||||||
{{on "change" this.updateApi}}
|
|
||||||
>
|
|
||||||
{{#each this.settings.overpassApis as |api|}}
|
|
||||||
<option
|
|
||||||
value={{api.url}}
|
|
||||||
selected={{if
|
|
||||||
(eq api.url this.settings.overpassApi)
|
|
||||||
"selected"
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{{api.name}}
|
|
||||||
</option>
|
|
||||||
{{/each}}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
<section class="settings-section">
|
|
||||||
<h3>About</h3>
|
|
||||||
<p>
|
|
||||||
<strong>Marco</strong>
|
|
||||||
(as in
|
|
||||||
<a
|
|
||||||
href="https://en.wikipedia.org/wiki/Marco_Polo"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener"
|
|
||||||
>Marco Polo</a>) is an unhosted maps application that respects your
|
|
||||||
privacy and choices.
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
Connect your own
|
|
||||||
<a
|
|
||||||
href="https://remotestorage.io/"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener"
|
|
||||||
>remote storage</a>
|
|
||||||
to sync place bookmarks across apps and devices.
|
|
||||||
</p>
|
|
||||||
<ul class="link-list">
|
|
||||||
<li>
|
|
||||||
<a
|
|
||||||
href="https://gitea.kosmos.org/raucao/marco"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener"
|
|
||||||
>
|
|
||||||
Source Code
|
|
||||||
</a>
|
|
||||||
(<a
|
|
||||||
href="https://en.wikipedia.org/wiki/GNU_Affero_General_Public_License"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener"
|
|
||||||
>AGPL</a>)
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<a
|
|
||||||
href="https://openstreetmap.org/copyright"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener"
|
|
||||||
>
|
|
||||||
Map Data © OpenStreetMap
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
}
|
|
||||||
45
app/icons/icon-rounded.svg
Normal file
45
app/icons/icon-rounded.svg
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
<svg
|
||||||
|
width="1024"
|
||||||
|
height="1024"
|
||||||
|
viewBox="0 0 1024 1024"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<!-- Background -->
|
||||||
|
<rect
|
||||||
|
x="0"
|
||||||
|
y="0"
|
||||||
|
width="1024"
|
||||||
|
height="1024"
|
||||||
|
rx="220"
|
||||||
|
fill="#F6E9A6"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Subtle map grid (kept well outside safe zone) -->
|
||||||
|
<g stroke="#E6D88A" stroke-width="10" opacity="0.6">
|
||||||
|
<line x1="256" y1="0" x2="256" y2="1024" />
|
||||||
|
<line x1="512" y1="0" x2="512" y2="1024" />
|
||||||
|
<line x1="768" y1="0" x2="768" y2="1024" />
|
||||||
|
|
||||||
|
<line x1="0" y1="256" x2="1024" y2="256" />
|
||||||
|
<line x1="0" y1="512" x2="1024" y2="512" />
|
||||||
|
<line x1="0" y1="768" x2="1024" y2="768" />
|
||||||
|
</g>
|
||||||
|
|
||||||
|
<!-- Location pin (exact app shape, larger, centered, safe-zone compliant) -->
|
||||||
|
<!-- Safe zone target: ~680px diameter -->
|
||||||
|
<g
|
||||||
|
transform="
|
||||||
|
translate(512 512)
|
||||||
|
scale(22)
|
||||||
|
translate(-12 -12)
|
||||||
|
"
|
||||||
|
fill="#ea4335"
|
||||||
|
stroke="#b31412"
|
||||||
|
stroke-width="1"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
|
<path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z" />
|
||||||
|
<circle cx="12" cy="10" r="3" fill="#b31412" stroke="none" />
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.1 KiB |
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 |
21
app/modifiers/on-click-outside.js
Normal file
21
app/modifiers/on-click-outside.js
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { modifier } from 'ember-modifier';
|
||||||
|
|
||||||
|
export default modifier((element, [callback]) => {
|
||||||
|
const handler = (event) => {
|
||||||
|
// Check if the click target is contained within the element
|
||||||
|
if (element && !element.contains(event.target)) {
|
||||||
|
callback(event);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Delay attaching the listener to avoid catching the opening click
|
||||||
|
// (using a microtask or setTimeout 0)
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
document.addEventListener('click', handler);
|
||||||
|
}, 0);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
document.removeEventListener('click', handler);
|
||||||
|
};
|
||||||
|
});
|
||||||
@@ -48,10 +48,33 @@ 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);
|
const options = { preventZoom: this.mapUi.preventNextZoom };
|
||||||
|
this.mapUi.selectPlace(model, options);
|
||||||
|
this.mapUi.preventNextZoom = false;
|
||||||
}
|
}
|
||||||
// Stop the pulse animation if it was running (e.g. redirected from search)
|
// Stop the pulse animation if it was running (e.g. redirected from search)
|
||||||
this.mapUi.stopSearch();
|
this.mapUi.stopSearch();
|
||||||
|
|||||||
@@ -9,18 +9,24 @@ export default class MapUiService extends Service {
|
|||||||
@tracked returnToSearch = false;
|
@tracked returnToSearch = false;
|
||||||
@tracked currentCenter = null;
|
@tracked currentCenter = null;
|
||||||
@tracked searchBoxHasFocus = false;
|
@tracked searchBoxHasFocus = false;
|
||||||
|
@tracked selectionOptions = {};
|
||||||
|
@tracked preventNextZoom = false;
|
||||||
|
|
||||||
selectPlace(place) {
|
selectPlace(place, options = {}) {
|
||||||
this.selectedPlace = place;
|
this.selectedPlace = place;
|
||||||
|
this.selectionOptions = options;
|
||||||
}
|
}
|
||||||
|
|
||||||
clearSelection() {
|
clearSelection() {
|
||||||
this.selectedPlace = null;
|
this.selectedPlace = null;
|
||||||
|
this.selectionOptions = {};
|
||||||
|
this.preventNextZoom = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
startSearch() {
|
startSearch() {
|
||||||
this.isSearching = true;
|
this.isSearching = true;
|
||||||
this.isCreating = false;
|
this.isCreating = false;
|
||||||
|
this.preventNextZoom = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
stopSearch() {
|
stopSearch() {
|
||||||
|
|||||||
@@ -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();
|
||||||
@@ -165,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) {
|
||||||
@@ -188,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)
|
||||||
@@ -204,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);
|
||||||
@@ -213,32 +291,61 @@ 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 = mainElement.tags || {};
|
const tags = displayElement.tags || {};
|
||||||
const type = getPlaceType(tags) || 'Point of Interest';
|
const type = getPlaceType(tags) || 'Point of Interest';
|
||||||
|
|
||||||
return {
|
return {
|
||||||
title: getLocalizedName(tags),
|
title: getLocalizedName(tags),
|
||||||
lat,
|
lat,
|
||||||
lon,
|
lon,
|
||||||
|
bbox,
|
||||||
|
geojson,
|
||||||
url: tags.website,
|
url: tags.website,
|
||||||
osmId: String(mainElement.id),
|
osmId: String(displayElement.id),
|
||||||
osmType: mainElement.type,
|
osmType: displayElement.type,
|
||||||
osmTags: tags,
|
osmTags: tags,
|
||||||
description: tags.description,
|
description: tags.description,
|
||||||
source: 'osm',
|
source: 'osm',
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
import Service from '@ember/service';
|
import Service, { service } from '@ember/service';
|
||||||
import { getPlaceType } from '../utils/osm';
|
import { getPlaceType } from '../utils/osm';
|
||||||
import { humanizeOsmTag } from '../utils/format-text';
|
import { humanizeOsmTag } from '../utils/format-text';
|
||||||
|
|
||||||
export default class PhotonService extends Service {
|
export default class PhotonService extends Service {
|
||||||
baseUrl = 'https://photon.komoot.io/api/';
|
@service settings;
|
||||||
|
|
||||||
|
get baseUrl() {
|
||||||
|
return this.settings.photonApi;
|
||||||
|
}
|
||||||
|
|
||||||
async search(query, lat, lon, limit = 10) {
|
async search(query, lat, lon, limit = 10) {
|
||||||
if (!query || query.length < 2) return [];
|
if (!query || query.length < 2) return [];
|
||||||
|
|||||||
@@ -2,16 +2,34 @@ 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;
|
||||||
|
@tracked photonApi = 'https://photon.komoot.io/api/';
|
||||||
|
|
||||||
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'
|
||||||
|
// },
|
||||||
|
];
|
||||||
|
|
||||||
|
photonApis = [
|
||||||
|
{
|
||||||
|
name: 'photon.komoot.io',
|
||||||
|
url: 'https://photon.komoot.io/api/',
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
@@ -22,7 +40,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');
|
||||||
@@ -41,4 +67,8 @@ export default class SettingsService extends Service {
|
|||||||
this.mapKinetic = enabled;
|
this.mapKinetic = enabled;
|
||||||
localStorage.setItem('marco:map-kinetic', String(enabled));
|
localStorage.setItem('marco:map-kinetic', String(enabled));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
updatePhotonApi(url) {
|
||||||
|
this.photonApi = url;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ export default class StorageService extends Service {
|
|||||||
@tracked savedPlaces = [];
|
@tracked savedPlaces = [];
|
||||||
@tracked loadedPrefixes = [];
|
@tracked loadedPrefixes = [];
|
||||||
@tracked currentBbox = null;
|
@tracked currentBbox = null;
|
||||||
|
@tracked lists = [];
|
||||||
@tracked version = 0; // Shared version tracker for bookmarks
|
@tracked version = 0; // Shared version tracker for bookmarks
|
||||||
@tracked initialSyncDone = false;
|
@tracked initialSyncDone = false;
|
||||||
@tracked connected = false;
|
@tracked connected = false;
|
||||||
@@ -46,6 +47,11 @@ export default class StorageService extends Service {
|
|||||||
this.rs.on('connected', () => {
|
this.rs.on('connected', () => {
|
||||||
this.connected = true;
|
this.connected = true;
|
||||||
this.userAddress = this.rs.remote.userAddress;
|
this.userAddress = this.rs.remote.userAddress;
|
||||||
|
this.loadLists();
|
||||||
|
});
|
||||||
|
|
||||||
|
this.rs.on('not-connected', () => {
|
||||||
|
this.loadLists();
|
||||||
});
|
});
|
||||||
|
|
||||||
this.rs.on('disconnected', () => {
|
this.rs.on('disconnected', () => {
|
||||||
@@ -54,6 +60,7 @@ export default class StorageService extends Service {
|
|||||||
this.placesInView = [];
|
this.placesInView = [];
|
||||||
this.savedPlaces = [];
|
this.savedPlaces = [];
|
||||||
this.loadedPrefixes = [];
|
this.loadedPrefixes = [];
|
||||||
|
this.lists = [];
|
||||||
this.initialSyncDone = false;
|
this.initialSyncDone = false;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -61,13 +68,18 @@ export default class StorageService extends Service {
|
|||||||
// console.debug('[rs] sync done:', result);
|
// console.debug('[rs] sync done:', result);
|
||||||
if (!this.initialSyncDone) {
|
if (!this.initialSyncDone) {
|
||||||
this.initialSyncDone = true;
|
this.initialSyncDone = true;
|
||||||
|
this.loadLists();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
this.rs.scope('/places/').on('change', (event) => {
|
this.rs.scope('/places/').on('change', (event) => {
|
||||||
// console.debug(event);
|
// console.debug(event);
|
||||||
this.handlePlaceChange(event);
|
if (event.relativePath.startsWith('_lists/')) {
|
||||||
debounceTask(this, 'reloadCurrentView', 200);
|
this.loadLists();
|
||||||
|
} else {
|
||||||
|
this.handlePlaceChange(event);
|
||||||
|
debounceTask(this, 'reloadCurrentView', 200);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -120,6 +132,98 @@ export default class StorageService extends Service {
|
|||||||
this.loadAllPlaces(required);
|
this.loadAllPlaces(required);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async loadLists() {
|
||||||
|
try {
|
||||||
|
if (!this.places.lists) return; // Wait for module init
|
||||||
|
|
||||||
|
// Ensure defaults exist first
|
||||||
|
await this.places.lists.initDefaults();
|
||||||
|
|
||||||
|
const lists = await this.places.lists.getAll();
|
||||||
|
this.lists = lists || [];
|
||||||
|
|
||||||
|
// Decorate with hardcoded icons for default lists (in-memory only)
|
||||||
|
this.lists.forEach((list) => {
|
||||||
|
if (list.id === 'to-go') {
|
||||||
|
list.icon = 'bookmark';
|
||||||
|
} else if (list.id === 'to-do') {
|
||||||
|
list.icon = 'check-square';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.refreshPlaceListAssociations();
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to load lists:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
refreshPlaceListAssociations() {
|
||||||
|
// 1. Build an index of PlaceID -> ListID[]
|
||||||
|
const placeToListMap = new Map();
|
||||||
|
|
||||||
|
this.lists.forEach((list) => {
|
||||||
|
if (list.placeRefs && Array.isArray(list.placeRefs)) {
|
||||||
|
list.placeRefs.forEach((ref) => {
|
||||||
|
if (!ref.id) return;
|
||||||
|
if (!placeToListMap.has(ref.id)) {
|
||||||
|
placeToListMap.set(ref.id, []);
|
||||||
|
}
|
||||||
|
placeToListMap.get(ref.id).push(list.id);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. Helper to attach lists to a place object
|
||||||
|
const attachLists = (place) => {
|
||||||
|
const listIds = placeToListMap.get(place.id) || [];
|
||||||
|
// Assign directly to object property (non-tracked mutation is fine as we trigger updates below)
|
||||||
|
place._listIds = listIds;
|
||||||
|
return place;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 3. Update savedPlaces
|
||||||
|
this.savedPlaces = this.savedPlaces.map((p) => attachLists({ ...p }));
|
||||||
|
|
||||||
|
// 4. Update placesInView
|
||||||
|
this.placesInView = this.placesInView.map((p) => attachLists({ ...p }));
|
||||||
|
}
|
||||||
|
|
||||||
|
async togglePlaceList(place, listId, shouldBeInList) {
|
||||||
|
if (!place) return;
|
||||||
|
|
||||||
|
// Ensure place is saved first if it's new
|
||||||
|
let savedPlace = place;
|
||||||
|
if (!place.id || !place.geohash) {
|
||||||
|
if (shouldBeInList) {
|
||||||
|
// If adding to a list, we must save the place first
|
||||||
|
savedPlace = await this.storePlace(place);
|
||||||
|
} else {
|
||||||
|
return; // Can't remove an unsaved place from a list
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (shouldBeInList) {
|
||||||
|
await this.places.lists.addPlace(
|
||||||
|
listId,
|
||||||
|
savedPlace.id,
|
||||||
|
savedPlace.geohash
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
await this.places.lists.removePlace(listId, savedPlace.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reload lists to reflect changes
|
||||||
|
await this.loadLists();
|
||||||
|
|
||||||
|
// Return the updated place
|
||||||
|
return this.findPlaceById(savedPlace.id);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to toggle place in list:', e);
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async loadPlacesInBounds(bbox) {
|
async loadPlacesInBounds(bbox) {
|
||||||
// 1. Calculate required prefixes
|
// 1. Calculate required prefixes
|
||||||
const requiredPrefixes = getGeohashPrefixesInBbox(bbox);
|
const requiredPrefixes = getGeohashPrefixesInBbox(bbox);
|
||||||
@@ -173,6 +277,8 @@ export default class StorageService extends Service {
|
|||||||
// Full reload
|
// Full reload
|
||||||
this.placesInView = places;
|
this.placesInView = places;
|
||||||
}
|
}
|
||||||
|
// Refresh list associations
|
||||||
|
this.refreshPlaceListAssociations();
|
||||||
} else {
|
} else {
|
||||||
if (!prefixes) this.placesInView = [];
|
if (!prefixes) this.placesInView = [];
|
||||||
}
|
}
|
||||||
@@ -190,11 +296,22 @@ export default class StorageService extends Service {
|
|||||||
let place = this.savedPlaces.find((p) => p.id && String(p.id) === strId);
|
let place = this.savedPlaces.find((p) => p.id && String(p.id) === strId);
|
||||||
if (place) return place;
|
if (place) return place;
|
||||||
|
|
||||||
|
// Check placesInView as fallback
|
||||||
|
place = this.placesInView.find((p) => p.id && String(p.id) === strId);
|
||||||
|
if (place) return place;
|
||||||
|
|
||||||
// Then search by OSM ID
|
// Then search by OSM ID
|
||||||
place = this.savedPlaces.find((p) => p.osmId && String(p.osmId) === strId);
|
place = this.savedPlaces.find((p) => p.osmId && String(p.osmId) === strId);
|
||||||
|
if (place) return place;
|
||||||
|
|
||||||
|
place = this.placesInView.find((p) => p.osmId && String(p.osmId) === strId);
|
||||||
return place;
|
return place;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
isPlaceSaved(id) {
|
||||||
|
return !!this.findPlaceById(id);
|
||||||
|
}
|
||||||
|
|
||||||
async storePlace(placeData) {
|
async storePlace(placeData) {
|
||||||
const savedPlace = await this.places.store(placeData);
|
const savedPlace = await this.places.store(placeData);
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,13 @@
|
|||||||
/* Ember supports plain CSS out of the box. More info: https://cli.emberjs.com/release/advanced-use/stylesheets/ */
|
/* Ember supports plain CSS out of the box. More info: https://cli.emberjs.com/release/advanced-use/stylesheets/ */
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--default-list-color: #fc3;
|
||||||
|
--hover-bg: #f8f9fa;
|
||||||
|
--sidebar-width: 360px;
|
||||||
|
--link-color: #2a7fff;
|
||||||
|
--link-color-visited: #6a4fbf;
|
||||||
|
}
|
||||||
|
|
||||||
html,
|
html,
|
||||||
body {
|
body {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
@@ -180,7 +188,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.text-primary {
|
.text-primary {
|
||||||
color: #007bff;
|
color: var(--link-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
.text-danger {
|
.text-danger {
|
||||||
@@ -197,20 +205,21 @@ body {
|
|||||||
top: 0;
|
top: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
width: 300px;
|
width: var(--sidebar-width);
|
||||||
background: white;
|
background: white;
|
||||||
z-index: 3100; /* Higher than Header (3000) */
|
z-index: 3100; /* Higher than Header (3000) */
|
||||||
box-shadow: 2px 0 5px rgb(0 0 0 / 10%);
|
box-shadow: 2px 0 5px rgb(0 0 0 / 10%);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
overflow: hidden; /* Ensure flex children are contained */
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-pane.sidebar {
|
.sidebar.app-menu-pane {
|
||||||
z-index: 3200; /* Higher than Places Sidebar (3100) */
|
z-index: 3200; /* Higher than Places Sidebar (3100) */
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (width <= 768px) {
|
@media (width <= 768px) {
|
||||||
.settings-pane.sidebar {
|
.sidebar.app-menu-pane {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
right: 0;
|
right: 0;
|
||||||
border-radius: 16px 16px 0 0;
|
border-radius: 16px 16px 0 0;
|
||||||
@@ -239,13 +248,131 @@ body {
|
|||||||
.sidebar-content {
|
.sidebar-content {
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
flex: 1; /* Take up remaining vertical space */
|
-webkit-overflow-scrolling: touch;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
touch-action: pan-y;
|
||||||
|
overscroll-behavior: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-menu-pane .sidebar-content {
|
||||||
|
padding-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-menu {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0 -1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-menu button {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.8rem;
|
||||||
|
padding: 1rem;
|
||||||
|
padding-left: 1.4rem;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: #333;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-family: inherit;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-menu button:hover {
|
||||||
|
background-color: var(--hover-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-menu .icon {
|
||||||
|
color: #666;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-content details {
|
||||||
|
margin: 0 -1rem; /* Top margin, negative side margins to span full width */
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-content details summary {
|
||||||
|
list-style: none; /* Hide default triangle */
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.8rem;
|
||||||
|
padding: 1rem;
|
||||||
|
padding-left: 1.4rem;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
color: #333;
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-content details summary::-webkit-details-marker {
|
||||||
|
display: none; /* Hide default triangle in WebKit */
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-content details summary:hover {
|
||||||
|
background-color: var(--hover-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-content details summary .icon {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-content details summary::after {
|
||||||
|
content: '';
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='%23666' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='9 18 15 12 9 6'/%3E%3C/svg%3E");
|
||||||
|
background-size: 20px 20px;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-position: center;
|
||||||
|
margin-left: auto;
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-content details[open] summary::after {
|
||||||
|
transform: rotate(90deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-content details .details-content {
|
||||||
|
padding: 0 1.4rem 1rem;
|
||||||
|
animation: details-slide-down 0.2s ease-out;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-content details .link-list {
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes details-slide-down {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-5px);
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-content details .link-list li {
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-content details .link-list li:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.edit-form {
|
.edit-form {
|
||||||
margin: -1rem;
|
margin: -1rem;
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
background: #f8f9fa;
|
background: var(--hover-bg);
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
border-bottom: 1px solid #eee;
|
border-bottom: 1px solid #eee;
|
||||||
}
|
}
|
||||||
@@ -269,12 +396,25 @@ body {
|
|||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
box-sizing: border-box; /* Ensure padding doesn't overflow width */
|
box-sizing: border-box; /* Ensure padding doesn't overflow width */
|
||||||
|
color: #333;
|
||||||
|
background-color: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-control:focus {
|
.form-control:focus {
|
||||||
outline: none;
|
outline: none;
|
||||||
border-color: #007bff;
|
border-color: var(--link-color);
|
||||||
box-shadow: 0 0 0 2px rgb(0 123 255 / 10%);
|
box-shadow: 0 0 0 2px rgb(42 127 255 / 10%);
|
||||||
|
}
|
||||||
|
|
||||||
|
select.form-control {
|
||||||
|
appearance: none;
|
||||||
|
background-color: #fff;
|
||||||
|
background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%23666' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E");
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-position: right 0.75rem center;
|
||||||
|
background-size: 16px 16px;
|
||||||
|
padding-right: 2.5rem;
|
||||||
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.edit-actions {
|
.edit-actions {
|
||||||
@@ -285,27 +425,27 @@ body {
|
|||||||
|
|
||||||
.settings-section {
|
.settings-section {
|
||||||
margin-bottom: 2rem;
|
margin-bottom: 2rem;
|
||||||
}
|
font-size: 0.95rem;
|
||||||
|
|
||||||
.settings-section h3 {
|
|
||||||
font-size: 1rem;
|
|
||||||
font-weight: bold;
|
|
||||||
color: #666;
|
|
||||||
margin: 0 0 0.5rem;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.5px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-section .form-group {
|
.settings-section .form-group {
|
||||||
margin-top: 1rem;
|
margin-top: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-section p a {
|
.about-section {
|
||||||
color: #007bff;
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-section a {
|
||||||
|
color: var(--link-color);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-section p a:hover {
|
.about-section a:visited {
|
||||||
|
color: var(--link-color-visited);
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-section a:hover {
|
||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -314,7 +454,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.btn-primary {
|
.btn-primary {
|
||||||
background: #007bff;
|
background: var(--link-color);
|
||||||
color: white;
|
color: white;
|
||||||
border: none;
|
border: none;
|
||||||
padding: 0.75rem;
|
padding: 0.75rem;
|
||||||
@@ -343,15 +483,44 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.meta-info a {
|
.meta-info a {
|
||||||
color: #007bff;
|
color: var(--link-color);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
padding-bottom: 4rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.meta-info a:hover {
|
.meta-info a:hover {
|
||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sidebar-content table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-content table th,
|
||||||
|
.sidebar-content table td {
|
||||||
|
padding: 0.5rem 0;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-content table th {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: bold;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: #898989;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-content table td {
|
||||||
|
border-bottom: 1px solid #f9f9f9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-content table tr:last-child td {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
abbr[title] {
|
||||||
|
text-decoration: underline dotted;
|
||||||
|
}
|
||||||
|
|
||||||
.link-list {
|
.link-list {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
@@ -363,7 +532,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.link-list a {
|
.link-list a {
|
||||||
color: #007bff;
|
color: var(--link-color);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
font-size: 0.95rem;
|
font-size: 0.95rem;
|
||||||
}
|
}
|
||||||
@@ -375,10 +544,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 {
|
||||||
@@ -395,7 +561,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.place-item:hover {
|
.place-item:hover {
|
||||||
background: #eee;
|
background: var(--hover-bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.place-name {
|
.place-name {
|
||||||
@@ -431,6 +597,10 @@ body {
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.place-details {
|
||||||
|
padding-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
.place-details h3 {
|
.place-details h3 {
|
||||||
font-size: 1.2rem;
|
font-size: 1.2rem;
|
||||||
margin-top: 0;
|
margin-top: 0;
|
||||||
@@ -487,7 +657,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.btn-blue {
|
.btn-blue {
|
||||||
background: #007bff;
|
background: var(--link-color);
|
||||||
color: white;
|
color: white;
|
||||||
border: none;
|
border: none;
|
||||||
}
|
}
|
||||||
@@ -512,6 +682,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;
|
||||||
@@ -549,6 +720,7 @@ body {
|
|||||||
.ol-control.ol-attribution {
|
.ol-control.ol-attribution {
|
||||||
bottom: 1rem;
|
bottom: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ol-touch .ol-control.ol-attribution {
|
.ol-touch .ol-control.ol-attribution {
|
||||||
bottom: 0.5rem;
|
bottom: 0.5rem;
|
||||||
}
|
}
|
||||||
@@ -556,6 +728,7 @@ body {
|
|||||||
.ol-control.ol-zoom {
|
.ol-control.ol-zoom {
|
||||||
bottom: 3rem;
|
bottom: 3rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ol-touch .ol-control.ol-zoom {
|
.ol-touch .ol-control.ol-zoom {
|
||||||
bottom: 3.5rem;
|
bottom: 3.5rem;
|
||||||
}
|
}
|
||||||
@@ -563,6 +736,7 @@ body {
|
|||||||
.ol-control.ol-locate {
|
.ol-control.ol-locate {
|
||||||
bottom: 6.5rem;
|
bottom: 6.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ol-touch .ol-control.ol-locate {
|
.ol-touch .ol-control.ol-locate {
|
||||||
bottom: 8.5rem;
|
bottom: 8.5rem;
|
||||||
}
|
}
|
||||||
@@ -570,6 +744,7 @@ body {
|
|||||||
.ol-control.ol-rotate {
|
.ol-control.ol-rotate {
|
||||||
bottom: 9rem;
|
bottom: 9rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ol-touch .ol-control.ol-rotate {
|
.ol-touch .ol-control.ol-rotate {
|
||||||
bottom: 11.5rem;
|
bottom: 11.5rem;
|
||||||
}
|
}
|
||||||
@@ -592,6 +767,17 @@ body {
|
|||||||
|
|
||||||
/* Icons */
|
/* Icons */
|
||||||
|
|
||||||
|
.app-logo-icon {
|
||||||
|
display: inline-flex;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-logo-icon svg {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
span.icon {
|
span.icon {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
}
|
}
|
||||||
@@ -610,13 +796,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;
|
||||||
@@ -687,6 +882,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%;
|
||||||
@@ -706,16 +902,19 @@ span.icon {
|
|||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Sidebar is open (Desktop: Left 300px) */
|
/* Sidebar is open (Desktop: Left var(--sidebar-width)) */
|
||||||
/* We want to center in the remaining space (width - 300px) */
|
|
||||||
/* Center X = 300 + (width - 300) / 2 = 300 + width/2 - 150 = width/2 + 150 */
|
/* We want to center in the remaining space (width - var(--sidebar-width)) */
|
||||||
/* So shift left by 150px from center */
|
|
||||||
|
/* Center X = var(--sidebar-width) + (width - var(--sidebar-width)) / 2 = var(--sidebar-width)/2 + 50% */
|
||||||
|
|
||||||
.map-container.sidebar-open .map-crosshair {
|
.map-container.sidebar-open .map-crosshair {
|
||||||
left: calc(50% + 150px);
|
left: calc(50% + var(--sidebar-width) / 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
@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 */
|
||||||
@@ -753,7 +952,6 @@ button.create-place {
|
|||||||
|
|
||||||
.sidebar-content {
|
.sidebar-content {
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
overscroll-behavior: contain; /* Prevent scroll chaining */
|
|
||||||
|
|
||||||
/* Ensure content doesn't get hidden behind bottom safe areas on mobile */
|
/* Ensure content doesn't get hidden behind bottom safe areas on mobile */
|
||||||
padding-bottom: env(safe-area-inset-bottom, 20px);
|
padding-bottom: env(safe-area-inset-bottom, 20px);
|
||||||
@@ -769,19 +967,25 @@ button.create-place {
|
|||||||
z-index: 3002; /* Higher than menu button to be safe */
|
z-index: 3002; /* Higher than menu button to be safe */
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (width <= 768px) {
|
||||||
|
.search-box {
|
||||||
|
max-width: calc(100vw - 65px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.search-form {
|
.search-form {
|
||||||
display: flex;
|
display: flex;
|
||||||
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 */
|
||||||
@@ -799,7 +1003,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) */
|
||||||
@@ -823,6 +1027,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;
|
||||||
}
|
}
|
||||||
@@ -844,25 +1049,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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -880,7 +1071,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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -893,7 +1084,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;
|
||||||
@@ -926,7 +1117,7 @@ button.create-place {
|
|||||||
|
|
||||||
.search-result-item:hover,
|
.search-result-item:hover,
|
||||||
.search-result-item:focus {
|
.search-result-item:focus {
|
||||||
background: #f5f5f5;
|
background: var(--hover-bg);
|
||||||
outline: none;
|
outline: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -965,3 +1156,64 @@ button.create-place {
|
|||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
margin-top: 2px;
|
margin-top: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Place Lists Manager */
|
||||||
|
.save-button-wrapper {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.place-lists-manager {
|
||||||
|
position: absolute;
|
||||||
|
top: 100%;
|
||||||
|
left: 0;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
background: white;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 4px 12px rgb(0 0 0 / 15%);
|
||||||
|
width: 220px;
|
||||||
|
z-index: 10;
|
||||||
|
padding: 0.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.place-lists-manager .list-item {
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.place-lists-manager .list-item:hover {
|
||||||
|
background: var(--hover-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.place-lists-manager label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
width: 100%;
|
||||||
|
cursor: pointer;
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.place-lists-manager input[type='checkbox'] {
|
||||||
|
accent-color: var(--link-color);
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.place-lists-manager .list-color {
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
background-color: var(--default-list-color);
|
||||||
|
border-radius: 50%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
border: 1px solid rgb(0 0 0 / 10%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.place-lists-manager .divider {
|
||||||
|
height: 1px;
|
||||||
|
background: #eee;
|
||||||
|
margin: 0.5rem 0;
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import Component from '@glimmer/component';
|
|||||||
import { pageTitle } from 'ember-page-title';
|
import { pageTitle } from 'ember-page-title';
|
||||||
import Map from '#components/map';
|
import Map from '#components/map';
|
||||||
import AppHeader from '#components/app-header';
|
import AppHeader from '#components/app-header';
|
||||||
import SettingsPane from '#components/settings-pane';
|
import AppMenu from '#components/app-menu/index';
|
||||||
import { service } from '@ember/service';
|
import { service } from '@ember/service';
|
||||||
import { tracked } from '@glimmer/tracking';
|
import { tracked } from '@glimmer/tracking';
|
||||||
import { action } from '@ember/object';
|
import { action } from '@ember/object';
|
||||||
@@ -14,7 +14,7 @@ export default class ApplicationComponent extends Component {
|
|||||||
@service mapUi;
|
@service mapUi;
|
||||||
@service router;
|
@service router;
|
||||||
|
|
||||||
@tracked isSettingsOpen = false;
|
@tracked isAppMenuOpen = false;
|
||||||
|
|
||||||
get isSidebarOpen() {
|
get isSidebarOpen() {
|
||||||
// We consider the sidebar "open" if we are in search or place routes.
|
// We consider the sidebar "open" if we are in search or place routes.
|
||||||
@@ -34,19 +34,19 @@ export default class ApplicationComponent extends Component {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@action
|
@action
|
||||||
toggleSettings() {
|
toggleAppMenu() {
|
||||||
this.isSettingsOpen = !this.isSettingsOpen;
|
this.isAppMenuOpen = !this.isAppMenuOpen;
|
||||||
}
|
}
|
||||||
|
|
||||||
@action
|
@action
|
||||||
closeSettings() {
|
closeAppMenu() {
|
||||||
this.isSettingsOpen = false;
|
this.isAppMenuOpen = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@action
|
@action
|
||||||
handleOutsideClick() {
|
handleOutsideClick() {
|
||||||
if (this.isSettingsOpen) {
|
if (this.isAppMenuOpen) {
|
||||||
this.closeSettings();
|
this.closeAppMenu();
|
||||||
} else if (this.router.currentRouteName === 'search') {
|
} else if (this.router.currentRouteName === 'search') {
|
||||||
this.router.transitionTo('index');
|
this.router.transitionTo('index');
|
||||||
} else if (this.router.currentRouteName === 'place') {
|
} else if (this.router.currentRouteName === 'place') {
|
||||||
@@ -65,7 +65,7 @@ export default class ApplicationComponent extends Component {
|
|||||||
<template>
|
<template>
|
||||||
{{pageTitle "Marco"}}
|
{{pageTitle "Marco"}}
|
||||||
|
|
||||||
<AppHeader @onToggleMenu={{this.toggleSettings}} />
|
<AppHeader @onToggleMenu={{this.toggleAppMenu}} />
|
||||||
|
|
||||||
<div
|
<div
|
||||||
id="rs-widget-container"
|
id="rs-widget-container"
|
||||||
@@ -81,12 +81,12 @@ export default class ApplicationComponent extends Component {
|
|||||||
{{/if}}
|
{{/if}}
|
||||||
|
|
||||||
<Map
|
<Map
|
||||||
@isSidebarOpen={{or this.isSidebarOpen this.isSettingsOpen}}
|
@isSidebarOpen={{or this.isSidebarOpen this.isAppMenuOpen}}
|
||||||
@onOutsideClick={{this.handleOutsideClick}}
|
@onOutsideClick={{this.handleOutsideClick}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{{#if this.isSettingsOpen}}
|
{{#if this.isAppMenuOpen}}
|
||||||
<SettingsPane @onClose={{this.closeSettings}} />
|
<AppMenu @onClose={{this.closeAppMenu}} />
|
||||||
{{/if}}
|
{{/if}}
|
||||||
|
|
||||||
{{outlet}}
|
{{outlet}}
|
||||||
|
|||||||
67
app/utils/icons.js
Normal file
67
app/utils/icons.js
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import arrowLeft from 'feather-icons/dist/icons/arrow-left.svg?raw';
|
||||||
|
import activity from 'feather-icons/dist/icons/activity.svg?raw';
|
||||||
|
import bookmark from 'feather-icons/dist/icons/bookmark.svg?raw';
|
||||||
|
import checkSquare from 'feather-icons/dist/icons/check-square.svg?raw';
|
||||||
|
import clock from 'feather-icons/dist/icons/clock.svg?raw';
|
||||||
|
import edit from 'feather-icons/dist/icons/edit.svg?raw';
|
||||||
|
import facebook from 'feather-icons/dist/icons/facebook.svg?raw';
|
||||||
|
import gift from 'feather-icons/dist/icons/gift.svg?raw';
|
||||||
|
import globe from 'feather-icons/dist/icons/globe.svg?raw';
|
||||||
|
import heart from 'feather-icons/dist/icons/heart.svg?raw';
|
||||||
|
import home from 'feather-icons/dist/icons/home.svg?raw';
|
||||||
|
import info from 'feather-icons/dist/icons/info.svg?raw';
|
||||||
|
import instagram from 'feather-icons/dist/icons/instagram.svg?raw';
|
||||||
|
import logIn from 'feather-icons/dist/icons/log-in.svg?raw';
|
||||||
|
import logOut from 'feather-icons/dist/icons/log-out.svg?raw';
|
||||||
|
import mail from 'feather-icons/dist/icons/mail.svg?raw';
|
||||||
|
import map from 'feather-icons/dist/icons/map.svg?raw';
|
||||||
|
import mapPin from 'feather-icons/dist/icons/map-pin.svg?raw';
|
||||||
|
import menu from 'feather-icons/dist/icons/menu.svg?raw';
|
||||||
|
import navigation from 'feather-icons/dist/icons/navigation.svg?raw';
|
||||||
|
import phone from 'feather-icons/dist/icons/phone.svg?raw';
|
||||||
|
import plus from 'feather-icons/dist/icons/plus.svg?raw';
|
||||||
|
import server from 'feather-icons/dist/icons/server.svg?raw';
|
||||||
|
import search from 'feather-icons/dist/icons/search.svg?raw';
|
||||||
|
import settings from 'feather-icons/dist/icons/settings.svg?raw';
|
||||||
|
import target from 'feather-icons/dist/icons/target.svg?raw';
|
||||||
|
import user from 'feather-icons/dist/icons/user.svg?raw';
|
||||||
|
import x from 'feather-icons/dist/icons/x.svg?raw';
|
||||||
|
import zap from 'feather-icons/dist/icons/zap.svg?raw';
|
||||||
|
import wikipedia from '../icons/wikipedia.svg?raw';
|
||||||
|
|
||||||
|
const ICONS = {
|
||||||
|
'arrow-left': arrowLeft,
|
||||||
|
activity,
|
||||||
|
bookmark,
|
||||||
|
'check-square': checkSquare,
|
||||||
|
clock,
|
||||||
|
edit,
|
||||||
|
facebook,
|
||||||
|
gift,
|
||||||
|
globe,
|
||||||
|
heart,
|
||||||
|
home,
|
||||||
|
info,
|
||||||
|
instagram,
|
||||||
|
'log-in': logIn,
|
||||||
|
'log-out': logOut,
|
||||||
|
mail,
|
||||||
|
map,
|
||||||
|
'map-pin': mapPin,
|
||||||
|
menu,
|
||||||
|
navigation,
|
||||||
|
phone,
|
||||||
|
plus,
|
||||||
|
server,
|
||||||
|
search,
|
||||||
|
settings,
|
||||||
|
target,
|
||||||
|
user,
|
||||||
|
wikipedia,
|
||||||
|
x,
|
||||||
|
zap,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getIcon(name) {
|
||||||
|
return ICONS[name];
|
||||||
|
}
|
||||||
@@ -33,21 +33,38 @@ export function getLocalizedName(tags, defaultName = 'Untitled Place') {
|
|||||||
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) {
|
export function getPlaceType(tags) {
|
||||||
if (!tags) return null;
|
if (!tags) return null;
|
||||||
|
|
||||||
const rawType =
|
for (const key of PLACE_TYPE_KEYS) {
|
||||||
tags.amenity ||
|
const value = tags[key];
|
||||||
tags.shop ||
|
if (value) {
|
||||||
tags.tourism ||
|
if (value === 'yes') {
|
||||||
tags.leisure ||
|
return humanizeOsmTag(key);
|
||||||
tags.office ||
|
}
|
||||||
tags.craft ||
|
return humanizeOsmTag(value);
|
||||||
tags.historic ||
|
}
|
||||||
tags.place ||
|
}
|
||||||
tags.building ||
|
|
||||||
tags.landuse ||
|
|
||||||
tags.natural;
|
|
||||||
|
|
||||||
return humanizeOsmTag(rawType);
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
15
app/utils/place-mapping.js
Normal file
15
app/utils/place-mapping.js
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { getLocalizedName } from './osm';
|
||||||
|
|
||||||
|
export function mapToStorageSchema(place) {
|
||||||
|
return {
|
||||||
|
title: place.title || getLocalizedName(place.osmTags, 'Untitled Place'),
|
||||||
|
lat: place.lat,
|
||||||
|
lon: place.lon,
|
||||||
|
tags: [],
|
||||||
|
url: place.osmTags?.website,
|
||||||
|
osmId: String(place.osmId || place.id),
|
||||||
|
osmType: place.osmType,
|
||||||
|
osmTags: place.osmTags || {},
|
||||||
|
description: place.description,
|
||||||
|
};
|
||||||
|
}
|
||||||
52
app/utils/social-links.js
Normal file
52
app/utils/social-links.js
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
// Helper to get value from multiple keys
|
||||||
|
const get = (tags, ...keys) => {
|
||||||
|
for (const k of keys) {
|
||||||
|
if (tags[k]) return tags[k];
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getSocialInfo(tags, platform) {
|
||||||
|
if (!tags) return null;
|
||||||
|
|
||||||
|
const key = platform;
|
||||||
|
const domain = `${platform}.com`;
|
||||||
|
const val = get(tags, `contact:${key}`, key);
|
||||||
|
|
||||||
|
if (!val) return null;
|
||||||
|
|
||||||
|
// Check if it's a full URL
|
||||||
|
if (val.startsWith('http')) {
|
||||||
|
try {
|
||||||
|
const url = new URL(val);
|
||||||
|
|
||||||
|
// Handle Facebook profile.php?id=...
|
||||||
|
if (
|
||||||
|
platform === 'facebook' &&
|
||||||
|
url.pathname === '/profile.php' &&
|
||||||
|
url.searchParams.has('id')
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
url: val,
|
||||||
|
username: url.searchParams.get('id'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up pathname to get username
|
||||||
|
let username = url.pathname.replace(/^\/|\/$/g, '');
|
||||||
|
return {
|
||||||
|
url: val,
|
||||||
|
username: username || val, // Fallback to full URL if path is empty
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return { url: val, username: val };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assume it's a username
|
||||||
|
const username = val.replace(/^@/, ''); // Remove leading @
|
||||||
|
return {
|
||||||
|
url: `https://${domain}/${username}`,
|
||||||
|
username: username,
|
||||||
|
};
|
||||||
|
}
|
||||||
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">
|
||||||
|
|||||||
11
package.json
11
package.json
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "marco",
|
"name": "marco",
|
||||||
"version": "1.11.4",
|
"version": "1.15.3",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "Unhosted maps app",
|
"description": "Unhosted maps app",
|
||||||
"repository": {
|
"repository": {
|
||||||
@@ -21,20 +21,21 @@
|
|||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "vite build --outDir release/",
|
"build": "vite build --outDir release/",
|
||||||
"build:icons": "for size in 32 48 144 180 192 512; do if [ \"$size\" -le 64 ]; then magick public/icons/icon.svg -define svg:remove-groups=map-grid -resize ${size}x${size} public/icons/icon-${size}.png; else rsvg-convert -w $size -h $size public/icons/icon.svg -o public/icons/icon-${size}.png; fi; done && rsvg-convert -w 512 -h 512 public/icons/icon.svg -o public/icons/icon-maskable.png",
|
"build:icons": "cp public/icons/icon-rounded.svg app/icons/icon-rounded.svg && for size in 32 48 144 180 192 512; do if [ \"$size\" -le 64 ]; then magick public/icons/icon.svg -define svg:remove-groups=map-grid -resize ${size}x${size} public/icons/icon-${size}.png; else rsvg-convert -w $size -h $size public/icons/icon.svg -o public/icons/icon-${size}.png; fi; done && rsvg-convert -w 512 -h 512 public/icons/icon.svg -o public/icons/icon-maskable.png",
|
||||||
"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",
|
||||||
"lint:js:fix": "eslint . --fix",
|
"lint:js:fix": "eslint . --fix",
|
||||||
"start": "vite",
|
"start": "vite",
|
||||||
"test": "vite build --mode development && testem ci --port 0",
|
"test": "vite build --mode development && testem ci --port 0",
|
||||||
"preversion": "pnpm test",
|
"preversion": "pnpm lint && pnpm test",
|
||||||
"version": "pnpm build && git add release/"
|
"version": "pnpm build && git add release/"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -51,7 +52,7 @@
|
|||||||
"@embroider/vite": "^1.5.0",
|
"@embroider/vite": "^1.5.0",
|
||||||
"@eslint/js": "^9.39.2",
|
"@eslint/js": "^9.39.2",
|
||||||
"@glimmer/component": "^2.0.0",
|
"@glimmer/component": "^2.0.0",
|
||||||
"@remotestorage/module-places": "1.x",
|
"@remotestorage/module-places": "~1.2.1",
|
||||||
"@rollup/plugin-babel": "^6.1.0",
|
"@rollup/plugin-babel": "^6.1.0",
|
||||||
"@warp-drive/core": "~5.8.0",
|
"@warp-drive/core": "~5.8.0",
|
||||||
"@warp-drive/ember": "~5.8.0",
|
"@warp-drive/ember": "~5.8.0",
|
||||||
|
|||||||
10
pnpm-lock.yaml
generated
10
pnpm-lock.yaml
generated
@@ -55,8 +55,8 @@ importers:
|
|||||||
specifier: ^2.0.0
|
specifier: ^2.0.0
|
||||||
version: 2.0.0
|
version: 2.0.0
|
||||||
'@remotestorage/module-places':
|
'@remotestorage/module-places':
|
||||||
specifier: 1.x
|
specifier: ~1.2.1
|
||||||
version: 1.0.0
|
version: 1.2.1
|
||||||
'@rollup/plugin-babel':
|
'@rollup/plugin-babel':
|
||||||
specifier: ^6.1.0
|
specifier: ^6.1.0
|
||||||
version: 6.1.0(@babel/core@7.28.6)(rollup@4.55.1)
|
version: 6.1.0(@babel/core@7.28.6)(rollup@4.55.1)
|
||||||
@@ -1380,8 +1380,8 @@ packages:
|
|||||||
resolution: {integrity: sha512-4rdu8GPY9TeQwsYp5D2My74dC3dSVS3tghAvisG80ybK4lqa0gvlrglaSTBxogJbxqHRw/NjI/liEtb3+SD+Bw==}
|
resolution: {integrity: sha512-4rdu8GPY9TeQwsYp5D2My74dC3dSVS3tghAvisG80ybK4lqa0gvlrglaSTBxogJbxqHRw/NjI/liEtb3+SD+Bw==}
|
||||||
engines: {node: '>=18.12'}
|
engines: {node: '>=18.12'}
|
||||||
|
|
||||||
'@remotestorage/module-places@1.0.0':
|
'@remotestorage/module-places@1.2.1':
|
||||||
resolution: {integrity: sha512-vaqJeTw658gjPyLz70Mq2AbGfDZ66O2mpDFME+gtaGFYl2+UvrvRLCrXWHYuyTE21f3TJdegeXM6C5nZMxLv9A==}
|
resolution: {integrity: sha512-hNRuhGoG8RS+cieVvDVzXWBEuNPfyeFirhgNH3z1WoKw9ngHdPY6V0sT0vKbsxB8xaODReZfo2ZKHLTmdFunlw==}
|
||||||
|
|
||||||
'@rollup/plugin-babel@6.1.0':
|
'@rollup/plugin-babel@6.1.0':
|
||||||
resolution: {integrity: sha512-dFZNuFD2YRcoomP4oYf+DvQNSUA9ih+A3vUqopQx5EdtPGo3WBnQcI/S8pwpz91UsGfL0HsMSOlaMld8HrbubA==}
|
resolution: {integrity: sha512-dFZNuFD2YRcoomP4oYf+DvQNSUA9ih+A3vUqopQx5EdtPGo3WBnQcI/S8pwpz91UsGfL0HsMSOlaMld8HrbubA==}
|
||||||
@@ -7002,7 +7002,7 @@ snapshots:
|
|||||||
'@pnpm/error': 1000.0.5
|
'@pnpm/error': 1000.0.5
|
||||||
find-up: 5.0.0
|
find-up: 5.0.0
|
||||||
|
|
||||||
'@remotestorage/module-places@1.0.0':
|
'@remotestorage/module-places@1.2.1':
|
||||||
dependencies:
|
dependencies:
|
||||||
latlon-geohash: 2.0.0
|
latlon-geohash: 2.0.0
|
||||||
ulid: 3.0.2
|
ulid: 3.0.2
|
||||||
|
|||||||
1
release/assets/main-CZskVGin.css
Normal file
1
release/assets/main-CZskVGin.css
Normal file
File diff suppressed because one or more lines are too long
2
release/assets/main-DMekdiSt.js
Normal file
2
release/assets/main-DMekdiSt.js
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
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-DMekdiSt.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/main-G8wPYi_P.css">
|
<link rel="stylesheet" crossorigin href="/assets/main-CZskVGin.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';
|
||||||
@@ -42,6 +42,9 @@ class MockStorageService extends Service {
|
|||||||
findPlaceById() {
|
findPlaceById() {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
isPlaceSaved() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
loadPlacesInBounds() {
|
loadPlacesInBounds() {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,6 +41,9 @@ module('Acceptance | search', function (hooks) {
|
|||||||
findPlaceById() {
|
findPlaceById() {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
isPlaceSaved() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
rs = {
|
rs = {
|
||||||
on: () => {},
|
on: () => {},
|
||||||
};
|
};
|
||||||
@@ -56,6 +59,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');
|
||||||
});
|
});
|
||||||
@@ -84,6 +88,9 @@ module('Acceptance | search', function (hooks) {
|
|||||||
findPlaceById() {
|
findPlaceById() {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
isPlaceSaved() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
rs = {
|
rs = {
|
||||||
on: () => {},
|
on: () => {},
|
||||||
};
|
};
|
||||||
@@ -99,6 +106,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');
|
||||||
});
|
});
|
||||||
@@ -128,6 +136,9 @@ module('Acceptance | search', function (hooks) {
|
|||||||
if (id === '999') return this.savedPlaces[0];
|
if (id === '999') return this.savedPlaces[0];
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
isPlaceSaved(id) {
|
||||||
|
return !!this.findPlaceById(id);
|
||||||
|
}
|
||||||
rs = {
|
rs = {
|
||||||
on: () => {},
|
on: () => {},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,11 +1,49 @@
|
|||||||
import { module, test } from 'qunit';
|
import { module, test } from 'qunit';
|
||||||
import { setupRenderingTest } from 'marco/tests/helpers';
|
import { setupRenderingTest } from 'marco/tests/helpers';
|
||||||
import { render } from '@ember/test-helpers';
|
import { render, click } from '@ember/test-helpers';
|
||||||
|
import Service from '@ember/service';
|
||||||
import PlaceDetails from 'marco/components/place-details';
|
import PlaceDetails from 'marco/components/place-details';
|
||||||
|
|
||||||
module('Integration | Component | place-details', function (hooks) {
|
module('Integration | Component | place-details', function (hooks) {
|
||||||
setupRenderingTest(hooks);
|
setupRenderingTest(hooks);
|
||||||
|
|
||||||
|
class StorageService extends Service {
|
||||||
|
lists = [
|
||||||
|
{ id: 'to-go', title: 'Want to go', color: '#2e9e4f' },
|
||||||
|
{ id: 'to-do', title: 'To do', color: '#2a7fff' },
|
||||||
|
];
|
||||||
|
|
||||||
|
isPlaceSaved() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
findPlaceById() {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async storePlace(place) {
|
||||||
|
return { ...place, id: '123', createdAt: new Date().toISOString() };
|
||||||
|
}
|
||||||
|
|
||||||
|
async removePlace() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async togglePlaceList() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
hooks.beforeEach(function () {
|
||||||
|
this.owner.register('service:storage', StorageService);
|
||||||
|
|
||||||
|
// Mock Router for all tests
|
||||||
|
class MockRouter extends Service {
|
||||||
|
transitionTo() {}
|
||||||
|
}
|
||||||
|
this.owner.register('service:router', MockRouter);
|
||||||
|
});
|
||||||
|
|
||||||
test('it formats coordinates correctly', async function (assert) {
|
test('it formats coordinates correctly', async function (assert) {
|
||||||
const place = {
|
const place = {
|
||||||
title: 'Test Place',
|
title: 'Test Place',
|
||||||
@@ -34,4 +72,187 @@ module('Integration | Component | place-details', function (hooks) {
|
|||||||
assert.dom('.place-details h3').hasText('Place without Coords');
|
assert.dom('.place-details h3').hasText('Place without Coords');
|
||||||
assert.dom('.meta-info a[href*="geo:"]').doesNotExist();
|
assert.dom('.meta-info a[href*="geo:"]').doesNotExist();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('it reveals the list manager when save is clicked', async function (assert) {
|
||||||
|
const place = {
|
||||||
|
title: 'Cool Cafe',
|
||||||
|
lat: 10,
|
||||||
|
lon: 10,
|
||||||
|
};
|
||||||
|
|
||||||
|
await render(<template><PlaceDetails @place={{place}} /></template>);
|
||||||
|
|
||||||
|
// Manager is initially hidden
|
||||||
|
assert.dom('.place-lists-manager').doesNotExist();
|
||||||
|
|
||||||
|
// Find the Save button
|
||||||
|
// It's the first button in .actions
|
||||||
|
const saveBtn = this.element.querySelector('.actions button');
|
||||||
|
await click(saveBtn);
|
||||||
|
|
||||||
|
// Manager should be visible now
|
||||||
|
assert.dom('.place-lists-manager').exists();
|
||||||
|
|
||||||
|
// Check for default lists from mock service
|
||||||
|
assert.dom('.place-lists-manager').includesText('Want to go');
|
||||||
|
assert.dom('.place-lists-manager').includesText('To do');
|
||||||
|
assert.dom('.place-lists-manager').includesText('Saved');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('it handles saving a new place via master toggle', async function (assert) {
|
||||||
|
let storedPlace = null;
|
||||||
|
|
||||||
|
// Override mock service specifically for this test to spy on storePlace
|
||||||
|
class MockStorage extends Service {
|
||||||
|
lists = [];
|
||||||
|
async storePlace(place) {
|
||||||
|
storedPlace = place;
|
||||||
|
return { ...place, id: 'new-id', createdAt: new Date().toISOString() };
|
||||||
|
}
|
||||||
|
isPlaceSaved() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
findPlaceById() {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.owner.register('service:storage', MockStorage);
|
||||||
|
|
||||||
|
const place = {
|
||||||
|
title: 'New Spot',
|
||||||
|
lat: 20,
|
||||||
|
lon: 20,
|
||||||
|
};
|
||||||
|
|
||||||
|
await render(<template><PlaceDetails @place={{place}} /></template>);
|
||||||
|
|
||||||
|
// Open manager
|
||||||
|
await click('.actions button');
|
||||||
|
|
||||||
|
// Find master "Saved" toggle
|
||||||
|
const masterToggle = this.element.querySelector(
|
||||||
|
'.place-lists-manager .master-toggle input'
|
||||||
|
);
|
||||||
|
|
||||||
|
// It should be unchecked initially for a new place
|
||||||
|
assert.dom(masterToggle).isNotChecked();
|
||||||
|
|
||||||
|
// Click it to save
|
||||||
|
await click(masterToggle);
|
||||||
|
|
||||||
|
// Verify storePlace was called
|
||||||
|
assert.ok(storedPlace, 'storePlace was called');
|
||||||
|
assert.strictEqual(storedPlace.title, 'New Spot');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('it handles removing a saved place via master toggle', async function (assert) {
|
||||||
|
let removedPlaceId = null;
|
||||||
|
|
||||||
|
class MockStorage extends Service {
|
||||||
|
lists = [];
|
||||||
|
async removePlace(place) {
|
||||||
|
removedPlaceId = place.id;
|
||||||
|
}
|
||||||
|
isPlaceSaved() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.owner.register('service:storage', MockStorage);
|
||||||
|
|
||||||
|
const place = {
|
||||||
|
id: 'saved-id',
|
||||||
|
title: 'Saved Spot',
|
||||||
|
lat: 30,
|
||||||
|
lon: 30,
|
||||||
|
createdAt: '2023-01-01', // Marks it as saved
|
||||||
|
};
|
||||||
|
|
||||||
|
await render(<template><PlaceDetails @place={{place}} /></template>);
|
||||||
|
|
||||||
|
// Open manager
|
||||||
|
await click('.actions button');
|
||||||
|
|
||||||
|
// Find master "Saved" toggle
|
||||||
|
const masterToggle = this.element.querySelector(
|
||||||
|
'.place-lists-manager .master-toggle input'
|
||||||
|
);
|
||||||
|
|
||||||
|
// It should be checked initially for a saved place
|
||||||
|
assert.dom(masterToggle).isChecked();
|
||||||
|
|
||||||
|
// Click it to remove
|
||||||
|
await click(masterToggle);
|
||||||
|
|
||||||
|
assert.strictEqual(removedPlaceId, 'saved-id', 'removePlace was called');
|
||||||
|
|
||||||
|
assert.deepEqual(place._listIds, [], '_listIds was cleared on the object');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('it adds place to a list', async function (assert) {
|
||||||
|
let listId = null;
|
||||||
|
let placeArg = null;
|
||||||
|
let shouldAdd = null;
|
||||||
|
|
||||||
|
class MockStorage extends Service {
|
||||||
|
lists = [{ id: 'favs', title: 'Favorites', color: 'red' }];
|
||||||
|
async togglePlaceList(place, id, add) {
|
||||||
|
placeArg = place;
|
||||||
|
listId = id;
|
||||||
|
shouldAdd = add;
|
||||||
|
}
|
||||||
|
isPlaceSaved() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.owner.register('service:storage', MockStorage);
|
||||||
|
|
||||||
|
// Provide a place that is already saved
|
||||||
|
const place = {
|
||||||
|
id: 'p1',
|
||||||
|
title: 'My Spot',
|
||||||
|
createdAt: '2023-01-01',
|
||||||
|
_listIds: [], // Not in any list yet
|
||||||
|
};
|
||||||
|
|
||||||
|
await render(<template><PlaceDetails @place={{place}} /></template>);
|
||||||
|
|
||||||
|
// Open manager
|
||||||
|
await click('.actions button');
|
||||||
|
|
||||||
|
// Find the checkbox for "Favorites"
|
||||||
|
const checkbox = this.element.querySelectorAll(
|
||||||
|
'.place-lists-manager input[type="checkbox"]'
|
||||||
|
)[1]; // Index 1 because 0 is master toggle
|
||||||
|
|
||||||
|
await click(checkbox);
|
||||||
|
|
||||||
|
assert.strictEqual(listId, 'favs');
|
||||||
|
assert.strictEqual(placeArg.id, 'p1');
|
||||||
|
assert.true(shouldAdd);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('it respects storage service state over stale place object', async function (assert) {
|
||||||
|
class MockStorage extends Service {
|
||||||
|
lists = [];
|
||||||
|
isPlaceSaved() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
findPlaceById() {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.owner.register('service:storage', MockStorage);
|
||||||
|
|
||||||
|
const place = {
|
||||||
|
id: 'stale-id',
|
||||||
|
title: 'Stale Place',
|
||||||
|
createdAt: '2023-01-01', // Looks saved
|
||||||
|
};
|
||||||
|
|
||||||
|
await render(<template><PlaceDetails @place={{place}} /></template>);
|
||||||
|
|
||||||
|
// Button should say "Save", not "Saved" because isPlaceSaved returns false
|
||||||
|
assert.dom('.actions button').hasText('Save');
|
||||||
|
assert.dom('.actions button').doesNotHaveClass('btn-secondary');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
58
tests/unit/utils/place-mapping-test.js
Normal file
58
tests/unit/utils/place-mapping-test.js
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import { mapToStorageSchema } from 'marco/utils/place-mapping';
|
||||||
|
import { module, test } from 'qunit';
|
||||||
|
|
||||||
|
module('Unit | Utility | place-mapping', function () {
|
||||||
|
test('it maps a raw place object to the storage schema', function (assert) {
|
||||||
|
const rawPlace = {
|
||||||
|
osmId: 12345,
|
||||||
|
osmType: 'node',
|
||||||
|
lat: 52.52,
|
||||||
|
lon: 13.405,
|
||||||
|
osmTags: {
|
||||||
|
name: 'Test Place',
|
||||||
|
website: 'https://example.com',
|
||||||
|
},
|
||||||
|
description: 'A test description',
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = mapToStorageSchema(rawPlace);
|
||||||
|
|
||||||
|
assert.strictEqual(result.title, 'Test Place');
|
||||||
|
assert.strictEqual(result.lat, 52.52);
|
||||||
|
assert.strictEqual(result.lon, 13.405);
|
||||||
|
assert.strictEqual(result.osmId, '12345');
|
||||||
|
assert.strictEqual(result.osmType, 'node');
|
||||||
|
assert.strictEqual(result.url, 'https://example.com');
|
||||||
|
assert.strictEqual(result.description, 'A test description');
|
||||||
|
assert.deepEqual(result.osmTags, rawPlace.osmTags);
|
||||||
|
assert.deepEqual(result.tags, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('it prioritizes place.title over osmTags.name', function (assert) {
|
||||||
|
const rawPlace = {
|
||||||
|
osmId: 123,
|
||||||
|
lat: 0,
|
||||||
|
lon: 0,
|
||||||
|
title: 'Custom Title',
|
||||||
|
osmTags: {
|
||||||
|
name: 'OSM Name',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = mapToStorageSchema(rawPlace);
|
||||||
|
assert.strictEqual(result.title, 'Custom Title');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('it handles fallback title correctly when no name is present', function (assert) {
|
||||||
|
const rawPlace = {
|
||||||
|
id: 987,
|
||||||
|
lat: 10,
|
||||||
|
lon: 20,
|
||||||
|
osmTags: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = mapToStorageSchema(rawPlace);
|
||||||
|
assert.strictEqual(result.title, 'Untitled Place');
|
||||||
|
assert.strictEqual(result.osmId, '987');
|
||||||
|
});
|
||||||
|
});
|
||||||
66
tests/unit/utils/social-links-test.js
Normal file
66
tests/unit/utils/social-links-test.js
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import { getSocialInfo } from 'marco/utils/social-links';
|
||||||
|
import { module, test } from 'qunit';
|
||||||
|
|
||||||
|
module('Unit | Utility | social-links', function () {
|
||||||
|
test('it returns null if tags are missing', function (assert) {
|
||||||
|
let result = getSocialInfo({}, 'facebook');
|
||||||
|
assert.strictEqual(result, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('it returns null if specific platform tags are missing', function (assert) {
|
||||||
|
let result = getSocialInfo({ twitter: 'foo' }, 'facebook');
|
||||||
|
assert.strictEqual(result, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('it handles simple usernames', function (assert) {
|
||||||
|
let result = getSocialInfo({ facebook: 'foo' }, 'facebook');
|
||||||
|
assert.deepEqual(result, {
|
||||||
|
url: 'https://facebook.com/foo',
|
||||||
|
username: 'foo',
|
||||||
|
});
|
||||||
|
|
||||||
|
result = getSocialInfo({ 'contact:instagram': '@bar' }, 'instagram');
|
||||||
|
assert.deepEqual(result, {
|
||||||
|
url: 'https://instagram.com/bar',
|
||||||
|
username: 'bar',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('it handles full URLs', function (assert) {
|
||||||
|
let result = getSocialInfo(
|
||||||
|
{ facebook: 'https://www.facebook.com/foo' },
|
||||||
|
'facebook'
|
||||||
|
);
|
||||||
|
assert.deepEqual(result, {
|
||||||
|
url: 'https://www.facebook.com/foo',
|
||||||
|
username: 'foo',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('it handles Facebook profile.php URLs', function (assert) {
|
||||||
|
let result = getSocialInfo(
|
||||||
|
{ facebook: 'https://www.facebook.com/profile.php?id=12345' },
|
||||||
|
'facebook'
|
||||||
|
);
|
||||||
|
assert.deepEqual(result, {
|
||||||
|
url: 'https://www.facebook.com/profile.php?id=12345',
|
||||||
|
username: '12345',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('it falls back gracefully for malformed URLs', function (assert) {
|
||||||
|
let result = getSocialInfo({ facebook: 'http://' }, 'facebook');
|
||||||
|
assert.deepEqual(result, {
|
||||||
|
url: 'http://',
|
||||||
|
username: 'http://',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('it prioritizes contact:tag over tag', function (assert) {
|
||||||
|
let result = getSocialInfo(
|
||||||
|
{ 'contact:facebook': 'priority', facebook: 'fallback' },
|
||||||
|
'facebook'
|
||||||
|
);
|
||||||
|
assert.strictEqual(result.username, 'priority');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -4,7 +4,7 @@ import { babel } from '@rollup/plugin-babel';
|
|||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
// server: {
|
// server: {
|
||||||
// host: '0.0.0.0'
|
// host: '0.0.0.0',
|
||||||
// },
|
// },
|
||||||
plugins: [
|
plugins: [
|
||||||
ember(),
|
ember(),
|
||||||
|
|||||||
Reference in New Issue
Block a user