Compare commits
33 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
2f440d4971
|
|||
|
1c6cbe6b0f
|
|||
|
bdd5db157c
|
|||
|
f7c40095d5
|
|||
|
579892067e
|
|||
|
48f87f98d6
|
|||
|
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
|
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
|
||||||
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 }}
|
||||||
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>
|
||||||
|
}
|
||||||
@@ -499,10 +499,9 @@ export default class MapComponent extends Component {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (options.preventZoom) {
|
if (options.preventZoom) {
|
||||||
// If we are preventing zoom (e.g. user clicked a bookmark), we still need to center
|
// If we are preventing zoom (e.g. user clicked a bookmark), we rely on visibility check.
|
||||||
// but without changing the zoom level.
|
// This avoids unnecessary panning if the place is already visible.
|
||||||
// We use animateToSmartCenter without a second argument (zoom=null).
|
this.handlePinVisibility(coords, { maintainZoom: true });
|
||||||
this.animateToSmartCenter(coords);
|
|
||||||
} else if (selected.bbox) {
|
} else if (selected.bbox) {
|
||||||
this.zoomToBbox(selected.bbox);
|
this.zoomToBbox(selected.bbox);
|
||||||
} else {
|
} else {
|
||||||
@@ -547,7 +546,10 @@ export default class MapComponent extends Component {
|
|||||||
}
|
}
|
||||||
// Desktop: Sidebar covers left side (approx 400px)
|
// Desktop: Sidebar covers left side (approx 400px)
|
||||||
else if (this.args.isSidebarOpen) {
|
else if (this.args.isSidebarOpen) {
|
||||||
const sidebarWidth = 400;
|
const sidebarWidthVar = getComputedStyle(document.documentElement)
|
||||||
|
.getPropertyValue('--sidebar-width')
|
||||||
|
.trim();
|
||||||
|
const sidebarWidth = parseInt(sidebarWidthVar, 10) || 360;
|
||||||
const visibleWidth = size[0] - sidebarWidth;
|
const visibleWidth = size[0] - sidebarWidth;
|
||||||
|
|
||||||
// Left padding: Sidebar + 15% of visible width
|
// Left padding: Sidebar + 15% of visible width
|
||||||
@@ -566,14 +568,15 @@ export default class MapComponent extends Component {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
handlePinVisibility(coords) {
|
handlePinVisibility(coords, options = {}) {
|
||||||
if (!this.mapInstance) return;
|
if (!this.mapInstance) return;
|
||||||
|
|
||||||
const view = this.mapInstance.getView();
|
const view = this.mapInstance.getView();
|
||||||
const currentZoom = view.getZoom();
|
const currentZoom = view.getZoom();
|
||||||
|
|
||||||
// If too far out (e.g. world view), zoom in to neighborhood level (16)
|
// If too far out (e.g. world view), zoom in to neighborhood level (16)
|
||||||
if (currentZoom < 16) {
|
// UNLESS we want to maintain the current zoom
|
||||||
|
if (!options.maintainZoom && currentZoom < 16) {
|
||||||
this.animateToSmartCenter(coords, 16);
|
this.animateToSmartCenter(coords, 16);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -590,8 +593,12 @@ 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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -627,6 +634,28 @@ 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;
|
||||||
|
|
||||||
|
// 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 = {
|
const animationOptions = {
|
||||||
center: targetCenter,
|
center: targetCenter,
|
||||||
@@ -645,33 +674,73 @@ export default class MapComponent extends Component {
|
|||||||
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
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -286,7 +286,7 @@ export default class PlaceDetails extends Component {
|
|||||||
>
|
>
|
||||||
<Icon
|
<Icon
|
||||||
@name="bookmark"
|
@name="bookmark"
|
||||||
@color={{if this.isSaved "currentColor" "#007bff"}}
|
@color={{if this.isSaved "currentColor" "var(--link-color)"}}
|
||||||
/>
|
/>
|
||||||
{{if this.isSaved "Saved" "Save"}}
|
{{if this.isSaved "Saved" "Save"}}
|
||||||
</button>
|
</button>
|
||||||
@@ -307,7 +307,7 @@ export default class PlaceDetails extends Component {
|
|||||||
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}}
|
||||||
|
|||||||
@@ -226,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}}
|
||||||
|
|||||||
@@ -1,128 +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';
|
|
||||||
|
|
||||||
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>
|
|
||||||
<img src="/icons/icon-rounded.svg" alt="" width="32" height="32" />
|
|
||||||
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 |
@@ -101,6 +101,23 @@ export default class PlaceRoute extends Route {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setupController(controller, model) {
|
||||||
|
super.setupController(controller, model);
|
||||||
|
this.checkUpdates(model);
|
||||||
|
}
|
||||||
|
|
||||||
|
async checkUpdates(place) {
|
||||||
|
// Only check for updates if it's a saved place (has ID) and is an OSM object
|
||||||
|
if (place && place.id && place.osmId && place.osmType) {
|
||||||
|
const updatedPlace = await this.storage.refreshPlace(place);
|
||||||
|
if (updatedPlace) {
|
||||||
|
// If an update occurred, refresh the map UI selection without moving the camera
|
||||||
|
// This ensures the sidebar shows the new data
|
||||||
|
this.mapUi.selectPlace(updatedPlace, { preventZoom: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
serialize(model) {
|
serialize(model) {
|
||||||
// If the model is a saved bookmark, use its ID
|
// If the model is a saved bookmark, use its ID
|
||||||
if (model.id) {
|
if (model.id) {
|
||||||
|
|||||||
@@ -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 [];
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { tracked } from '@glimmer/tracking';
|
|||||||
export default class SettingsService extends Service {
|
export default class SettingsService extends Service {
|
||||||
@tracked overpassApi = 'https://overpass-api.de/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 = [
|
||||||
{
|
{
|
||||||
@@ -24,6 +25,13 @@ export default class SettingsService extends Service {
|
|||||||
// },
|
// },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
photonApis = [
|
||||||
|
{
|
||||||
|
name: 'photon.komoot.io',
|
||||||
|
url: 'https://photon.komoot.io/api/',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super(...arguments);
|
super(...arguments);
|
||||||
this.loadSettings();
|
this.loadSettings();
|
||||||
@@ -59,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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import Service from '@ember/service';
|
import Service, { service } from '@ember/service';
|
||||||
import RemoteStorage from 'remotestoragejs';
|
import RemoteStorage from 'remotestoragejs';
|
||||||
import Places from '@remotestorage/module-places';
|
import Places from '@remotestorage/module-places';
|
||||||
import Widget from 'remotestorage-widget';
|
import Widget from 'remotestorage-widget';
|
||||||
@@ -7,8 +7,10 @@ import { getGeohashPrefixesInBbox } from '../utils/geohash-coverage';
|
|||||||
import { action } from '@ember/object';
|
import { action } from '@ember/object';
|
||||||
import { debounceTask } from 'ember-lifeline';
|
import { debounceTask } from 'ember-lifeline';
|
||||||
import Geohash from 'latlon-geohash';
|
import Geohash from 'latlon-geohash';
|
||||||
|
import { getLocalizedName } from '../utils/osm';
|
||||||
|
|
||||||
export default class StorageService extends Service {
|
export default class StorageService extends Service {
|
||||||
|
@service osm;
|
||||||
rs;
|
rs;
|
||||||
widget;
|
widget;
|
||||||
@tracked placesInView = [];
|
@tracked placesInView = [];
|
||||||
@@ -366,6 +368,82 @@ export default class StorageService extends Service {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async refreshPlace(place) {
|
||||||
|
if (!place || !place.id || !place.osmId || !place.osmType) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.debug(`Checking for updates for ${place.title} (${place.osmId})`);
|
||||||
|
const freshData = await this.osm.fetchOsmObject(
|
||||||
|
place.osmId,
|
||||||
|
place.osmType
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!freshData) {
|
||||||
|
console.warn('Could not fetch fresh data for', place.osmId);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for changes
|
||||||
|
let hasChanges = false;
|
||||||
|
const changes = {};
|
||||||
|
|
||||||
|
// 1. Check Coordinates (allow tiny drift < ~1m)
|
||||||
|
const latDiff = Math.abs(place.lat - freshData.lat);
|
||||||
|
const lonDiff = Math.abs(place.lon - freshData.lon);
|
||||||
|
if (latDiff > 0.00001 || lonDiff > 0.00001) {
|
||||||
|
hasChanges = true;
|
||||||
|
changes.lat = freshData.lat;
|
||||||
|
changes.lon = freshData.lon;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Check Tags
|
||||||
|
const oldTags = place.osmTags || {};
|
||||||
|
const newTags = freshData.osmTags || {};
|
||||||
|
const allKeys = new Set([
|
||||||
|
...Object.keys(oldTags),
|
||||||
|
...Object.keys(newTags),
|
||||||
|
]);
|
||||||
|
|
||||||
|
for (const key of allKeys) {
|
||||||
|
if (oldTags[key] !== newTags[key]) {
|
||||||
|
hasChanges = true;
|
||||||
|
changes.osmTags = newTags;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasChanges) {
|
||||||
|
console.debug('No changes detected for', place.title);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.debug('Changes detected:', changes);
|
||||||
|
|
||||||
|
// 3. Prepare Update
|
||||||
|
const updatedPlace = {
|
||||||
|
...place,
|
||||||
|
...changes,
|
||||||
|
};
|
||||||
|
|
||||||
|
// If the current title matches the old localized name, update it to the
|
||||||
|
// new localized name. If the user renamed it (custom title), keep it.
|
||||||
|
const oldDefaultName = getLocalizedName(oldTags);
|
||||||
|
const newDefaultName = getLocalizedName(newTags);
|
||||||
|
|
||||||
|
if (place.title === oldDefaultName && oldDefaultName !== newDefaultName) {
|
||||||
|
updatedPlace.title = newDefaultName;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Save
|
||||||
|
return await this.updatePlace(updatedPlace);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to refresh place:', e);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@action
|
@action
|
||||||
connect() {
|
connect() {
|
||||||
this.isWidgetOpen = true;
|
this.isWidgetOpen = true;
|
||||||
|
|||||||
@@ -2,6 +2,10 @@
|
|||||||
|
|
||||||
:root {
|
:root {
|
||||||
--default-list-color: #fc3;
|
--default-list-color: #fc3;
|
||||||
|
--hover-bg: #f8f9fa;
|
||||||
|
--sidebar-width: 350px;
|
||||||
|
--link-color: #2a7fff;
|
||||||
|
--link-color-visited: #6a4fbf;
|
||||||
}
|
}
|
||||||
|
|
||||||
html,
|
html,
|
||||||
@@ -184,7 +188,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.text-primary {
|
.text-primary {
|
||||||
color: #007bff;
|
color: var(--link-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
.text-danger {
|
.text-danger {
|
||||||
@@ -201,7 +205,7 @@ 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%);
|
||||||
@@ -210,12 +214,12 @@ body {
|
|||||||
overflow: hidden; /* Ensure flex children are contained */
|
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;
|
||||||
@@ -251,10 +255,111 @@ body {
|
|||||||
overscroll-behavior: contain;
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes details-slide-down {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-5px);
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(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;
|
||||||
}
|
}
|
||||||
@@ -278,12 +383,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 {
|
||||||
@@ -294,27 +412,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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -323,7 +441,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;
|
||||||
@@ -352,7 +470,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.meta-info a {
|
.meta-info a {
|
||||||
color: #007bff;
|
color: var(--link-color);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -360,24 +478,34 @@ body {
|
|||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
|
|
||||||
.link-list {
|
.sidebar-content table {
|
||||||
list-style: none;
|
width: 100%;
|
||||||
padding: 0;
|
border-collapse: collapse;
|
||||||
margin: 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.link-list li {
|
.sidebar-content table th,
|
||||||
margin-bottom: 0.5rem;
|
.sidebar-content table td {
|
||||||
|
padding: 0.5rem 0;
|
||||||
|
text-align: left;
|
||||||
}
|
}
|
||||||
|
|
||||||
.link-list a {
|
.sidebar-content table th {
|
||||||
color: #007bff;
|
font-size: 0.75rem;
|
||||||
text-decoration: none;
|
font-weight: bold;
|
||||||
font-size: 0.95rem;
|
text-transform: uppercase;
|
||||||
|
color: #898989;
|
||||||
}
|
}
|
||||||
|
|
||||||
.link-list a:hover {
|
.sidebar-content table td {
|
||||||
text-decoration: underline;
|
border-bottom: 1px solid #f9f9f9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-content table tr:last-child td {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
abbr[title] {
|
||||||
|
text-decoration: underline dotted;
|
||||||
}
|
}
|
||||||
|
|
||||||
.places-list {
|
.places-list {
|
||||||
@@ -400,7 +528,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.place-item:hover {
|
.place-item:hover {
|
||||||
background: #eee;
|
background: var(--hover-bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.place-name {
|
.place-name {
|
||||||
@@ -496,7 +624,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.btn-blue {
|
.btn-blue {
|
||||||
background: #007bff;
|
background: var(--link-color);
|
||||||
color: white;
|
color: white;
|
||||||
border: none;
|
border: none;
|
||||||
}
|
}
|
||||||
@@ -606,6 +734,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;
|
||||||
}
|
}
|
||||||
@@ -730,15 +869,14 @@ 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) */
|
/* We want to center in the remaining space (width - var(--sidebar-width)) */
|
||||||
|
|
||||||
/* Center X = 300 + (width - 300) / 2 = 300 + width/2 - 150 = width/2 + 150 */
|
/* Center X = var(--sidebar-width) + (width - var(--sidebar-width)) / 2 = var(--sidebar-width)/2 + 50% */
|
||||||
|
|
||||||
/* So shift left by 150px from center */
|
|
||||||
.map-container.sidebar-open .map-crosshair {
|
.map-container.sidebar-open .map-crosshair {
|
||||||
left: calc(50% + 150px);
|
left: calc(50% + var(--sidebar-width) / 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (width <= 768px) {
|
@media (width <= 768px) {
|
||||||
@@ -946,7 +1084,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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1011,7 +1149,7 @@ button.create-place {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.place-lists-manager .list-item:hover {
|
.place-lists-manager .list-item:hover {
|
||||||
background: #f8f9fa;
|
background: var(--hover-bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.place-lists-manager label {
|
.place-lists-manager label {
|
||||||
@@ -1026,7 +1164,7 @@ button.create-place {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.place-lists-manager input[type='checkbox'] {
|
.place-lists-manager input[type='checkbox'] {
|
||||||
accent-color: #007bff;
|
accent-color: var(--link-color);
|
||||||
width: 16px;
|
width: 16px;
|
||||||
height: 16px;
|
height: 16px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|||||||
@@ -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}}
|
||||||
|
|||||||
@@ -5,8 +5,11 @@ import checkSquare from 'feather-icons/dist/icons/check-square.svg?raw';
|
|||||||
import clock from 'feather-icons/dist/icons/clock.svg?raw';
|
import clock from 'feather-icons/dist/icons/clock.svg?raw';
|
||||||
import edit from 'feather-icons/dist/icons/edit.svg?raw';
|
import edit from 'feather-icons/dist/icons/edit.svg?raw';
|
||||||
import facebook from 'feather-icons/dist/icons/facebook.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 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 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 instagram from 'feather-icons/dist/icons/instagram.svg?raw';
|
||||||
import logIn from 'feather-icons/dist/icons/log-in.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 logOut from 'feather-icons/dist/icons/log-out.svg?raw';
|
||||||
@@ -34,8 +37,11 @@ const ICONS = {
|
|||||||
clock,
|
clock,
|
||||||
edit,
|
edit,
|
||||||
facebook,
|
facebook,
|
||||||
|
gift,
|
||||||
globe,
|
globe,
|
||||||
|
heart,
|
||||||
home,
|
home,
|
||||||
|
info,
|
||||||
instagram,
|
instagram,
|
||||||
'log-in': logIn,
|
'log-in': logIn,
|
||||||
'log-out': logOut,
|
'log-out': logOut,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "marco",
|
"name": "marco",
|
||||||
"version": "1.14.0",
|
"version": "1.16.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "Unhosted maps app",
|
"description": "Unhosted maps app",
|
||||||
"repository": {
|
"repository": {
|
||||||
@@ -21,7 +21,7 @@
|
|||||||
},
|
},
|
||||||
"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\"",
|
||||||
@@ -35,7 +35,7 @@
|
|||||||
"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": {
|
||||||
|
|||||||
1
release/assets/main-BOfcjRke.css
Normal file
1
release/assets/main-BOfcjRke.css
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
2
release/assets/main-gEUnNw-L.js
Normal file
2
release/assets/main-gEUnNw-L.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
@@ -39,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-GynTgP18.js"></script>
|
<script type="module" crossorigin src="/assets/main-gEUnNw-L.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/main-BT0n1kYB.css">
|
<link rel="stylesheet" crossorigin href="/assets/main-BOfcjRke.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -125,4 +125,60 @@ module('Unit | Route | place', function (hooks) {
|
|||||||
|
|
||||||
assert.notOk(fetchCalled, 'fetchOsmObject should NOT be called for nodes');
|
assert.notOk(fetchCalled, 'fetchOsmObject should NOT be called for nodes');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('setupController triggers checkUpdates', async function (assert) {
|
||||||
|
let route = this.owner.lookup('route:place');
|
||||||
|
|
||||||
|
// Stub Storage Service
|
||||||
|
let refreshPlaceCalled = false;
|
||||||
|
class StorageStub extends Service {
|
||||||
|
async refreshPlace(place) {
|
||||||
|
refreshPlaceCalled = true;
|
||||||
|
assert.strictEqual(place.id, '123', 'Passed correct place to storage');
|
||||||
|
return {
|
||||||
|
...place,
|
||||||
|
title: 'Updated Title',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stub MapUi Service
|
||||||
|
let selectPlaceCalled = false;
|
||||||
|
class MapUiStub extends Service {
|
||||||
|
selectPlace(place, options) {
|
||||||
|
selectPlaceCalled = true;
|
||||||
|
assert.strictEqual(
|
||||||
|
place.title,
|
||||||
|
'Updated Title',
|
||||||
|
'Selected updated place'
|
||||||
|
);
|
||||||
|
assert.ok(options.preventZoom, 'Prevented zoom on update');
|
||||||
|
}
|
||||||
|
stopSearch() {}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.owner.register('service:storage', StorageStub);
|
||||||
|
this.owner.register('service:map-ui', MapUiStub);
|
||||||
|
|
||||||
|
let model = {
|
||||||
|
id: '123',
|
||||||
|
osmId: '456',
|
||||||
|
osmType: 'node',
|
||||||
|
title: 'Original Title',
|
||||||
|
};
|
||||||
|
|
||||||
|
let controller = {};
|
||||||
|
|
||||||
|
// Trigger setupController
|
||||||
|
route.setupController(controller, model);
|
||||||
|
|
||||||
|
// checkUpdates is async and not awaited in setupController, so we need to wait a tick
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||||
|
|
||||||
|
assert.ok(refreshPlaceCalled, 'refreshPlace should be called');
|
||||||
|
assert.ok(
|
||||||
|
selectPlaceCalled,
|
||||||
|
'mapUi.selectPlace should be called with update'
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
202
tests/unit/services/storage-test.js
Normal file
202
tests/unit/services/storage-test.js
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
import { module, test } from 'qunit';
|
||||||
|
import { setupTest } from 'marco/tests/helpers';
|
||||||
|
import Service from '@ember/service';
|
||||||
|
|
||||||
|
module('Unit | Service | storage', function (hooks) {
|
||||||
|
setupTest(hooks);
|
||||||
|
|
||||||
|
test('refreshPlace skips invalid places', async function (assert) {
|
||||||
|
let service = this.owner.lookup('service:storage');
|
||||||
|
let result = await service.refreshPlace({});
|
||||||
|
assert.strictEqual(result, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('refreshPlace detects coordinate drift', async function (assert) {
|
||||||
|
let service = this.owner.lookup('service:storage');
|
||||||
|
|
||||||
|
// Stub OSM Service
|
||||||
|
class OsmStub extends Service {
|
||||||
|
async fetchOsmObject(id, type) {
|
||||||
|
return {
|
||||||
|
osmId: id,
|
||||||
|
osmType: type,
|
||||||
|
lat: 52.5201, // Changed significantly from 52.5200
|
||||||
|
lon: 13.405,
|
||||||
|
osmTags: { name: 'Foo' },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.owner.register('service:osm', OsmStub);
|
||||||
|
|
||||||
|
// Mock storage update
|
||||||
|
let updatePlaceCalled = false;
|
||||||
|
service.updatePlace = async (place) => {
|
||||||
|
updatePlaceCalled = true;
|
||||||
|
return place;
|
||||||
|
};
|
||||||
|
|
||||||
|
let place = {
|
||||||
|
id: '123',
|
||||||
|
osmId: '456',
|
||||||
|
osmType: 'node',
|
||||||
|
lat: 52.52,
|
||||||
|
lon: 13.405,
|
||||||
|
osmTags: { name: 'Foo' },
|
||||||
|
title: 'Foo',
|
||||||
|
};
|
||||||
|
|
||||||
|
let result = await service.refreshPlace(place);
|
||||||
|
|
||||||
|
assert.ok(updatePlaceCalled, 'updatePlace should be called');
|
||||||
|
assert.strictEqual(result.lat, 52.5201, 'Latitude updated');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('refreshPlace ignores tiny coordinate drift', async function (assert) {
|
||||||
|
let service = this.owner.lookup('service:storage');
|
||||||
|
|
||||||
|
class OsmStub extends Service {
|
||||||
|
async fetchOsmObject(id, type) {
|
||||||
|
return {
|
||||||
|
osmId: id,
|
||||||
|
osmType: type,
|
||||||
|
lat: 52.5200005, // Tiny change (< 0.00001)
|
||||||
|
lon: 13.405,
|
||||||
|
osmTags: { name: 'Foo' },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.owner.register('service:osm', OsmStub);
|
||||||
|
|
||||||
|
let updatePlaceCalled = false;
|
||||||
|
service.updatePlace = async () => {
|
||||||
|
updatePlaceCalled = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
let place = {
|
||||||
|
id: '123',
|
||||||
|
osmId: '456',
|
||||||
|
osmType: 'node',
|
||||||
|
lat: 52.52,
|
||||||
|
lon: 13.405,
|
||||||
|
osmTags: { name: 'Foo' },
|
||||||
|
title: 'Foo',
|
||||||
|
};
|
||||||
|
|
||||||
|
await service.refreshPlace(place);
|
||||||
|
|
||||||
|
assert.notOk(updatePlaceCalled, 'updatePlace should NOT be called');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('refreshPlace detects tag changes', async function (assert) {
|
||||||
|
let service = this.owner.lookup('service:storage');
|
||||||
|
|
||||||
|
class OsmStub extends Service {
|
||||||
|
async fetchOsmObject(id, type) {
|
||||||
|
return {
|
||||||
|
osmId: id,
|
||||||
|
osmType: type,
|
||||||
|
lat: 52.52,
|
||||||
|
lon: 13.405,
|
||||||
|
osmTags: { name: 'Bar' }, // Changed name
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.owner.register('service:osm', OsmStub);
|
||||||
|
|
||||||
|
let updatePlaceCalled = false;
|
||||||
|
service.updatePlace = async (place) => {
|
||||||
|
updatePlaceCalled = true;
|
||||||
|
return place;
|
||||||
|
};
|
||||||
|
|
||||||
|
let place = {
|
||||||
|
id: '123',
|
||||||
|
osmId: '456',
|
||||||
|
osmType: 'node',
|
||||||
|
lat: 52.52,
|
||||||
|
lon: 13.405,
|
||||||
|
osmTags: { name: 'Foo' },
|
||||||
|
title: 'Foo',
|
||||||
|
};
|
||||||
|
|
||||||
|
let result = await service.refreshPlace(place);
|
||||||
|
|
||||||
|
assert.ok(updatePlaceCalled, 'updatePlace should be called');
|
||||||
|
assert.strictEqual(result.osmTags.name, 'Bar', 'Tags updated');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('refreshPlace updates title if it was default', async function (assert) {
|
||||||
|
let service = this.owner.lookup('service:storage');
|
||||||
|
|
||||||
|
class OsmStub extends Service {
|
||||||
|
async fetchOsmObject(id, type) {
|
||||||
|
return {
|
||||||
|
osmId: id,
|
||||||
|
osmType: type,
|
||||||
|
lat: 52.52,
|
||||||
|
lon: 13.405,
|
||||||
|
osmTags: { name: 'New Name' },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.owner.register('service:osm', OsmStub);
|
||||||
|
|
||||||
|
service.updatePlace = async (place) => place;
|
||||||
|
|
||||||
|
let place = {
|
||||||
|
id: '123',
|
||||||
|
osmId: '456',
|
||||||
|
osmType: 'node',
|
||||||
|
lat: 52.52,
|
||||||
|
lon: 13.405,
|
||||||
|
osmTags: { name: 'Old Name' },
|
||||||
|
title: 'Old Name', // Matches default
|
||||||
|
};
|
||||||
|
|
||||||
|
let result = await service.refreshPlace(place);
|
||||||
|
|
||||||
|
assert.strictEqual(result.title, 'New Name', 'Title should update');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('refreshPlace preserves custom title', async function (assert) {
|
||||||
|
let service = this.owner.lookup('service:storage');
|
||||||
|
|
||||||
|
class OsmStub extends Service {
|
||||||
|
async fetchOsmObject(id, type) {
|
||||||
|
return {
|
||||||
|
osmId: id,
|
||||||
|
osmType: type,
|
||||||
|
lat: 52.52,
|
||||||
|
lon: 13.405,
|
||||||
|
osmTags: { name: 'New Name' },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.owner.register('service:osm', OsmStub);
|
||||||
|
|
||||||
|
service.updatePlace = async (place) => place;
|
||||||
|
|
||||||
|
let place = {
|
||||||
|
id: '123',
|
||||||
|
osmId: '456',
|
||||||
|
osmType: 'node',
|
||||||
|
lat: 52.52,
|
||||||
|
lon: 13.405,
|
||||||
|
osmTags: { name: 'Old Name' },
|
||||||
|
title: 'My Custom Place', // User renamed it
|
||||||
|
};
|
||||||
|
|
||||||
|
let result = await service.refreshPlace(place);
|
||||||
|
|
||||||
|
assert.strictEqual(
|
||||||
|
result.title,
|
||||||
|
'My Custom Place',
|
||||||
|
'Title should NOT update'
|
||||||
|
);
|
||||||
|
assert.strictEqual(
|
||||||
|
result.osmTags.name,
|
||||||
|
'New Name',
|
||||||
|
'Tags should still update'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user