Compare commits

..

1 Commits

Author SHA1 Message Date
raucao f6984fcbfe Refactor app menu, add place lists
Unify sidebar, make everything route-based
2026-06-30 13:02:18 +02:00
30 changed files with 262 additions and 890 deletions
+3 -52
View File
@@ -9,7 +9,6 @@ import SearchBox from '#components/search-box';
import CategoryChips from '#components/category-chips'; import CategoryChips from '#components/category-chips';
import { and } from 'ember-truth-helpers'; import { and } from 'ember-truth-helpers';
import cachedImage from '../modifiers/cached-image'; import cachedImage from '../modifiers/cached-image';
import { POI_CATEGORIES } from '../utils/poi-categories';
export default class AppHeaderComponent extends Component { export default class AppHeaderComponent extends Component {
@service storage; @service storage;
@@ -17,59 +16,11 @@ export default class AppHeaderComponent extends Component {
@service nostrAuth; @service nostrAuth;
@service nostrData; @service nostrData;
@service mapUi; @service mapUi;
@service router;
@tracked isUserMenuOpen = false; @tracked isUserMenuOpen = false;
@tracked searchQuery = ''; @tracked searchQuery = '';
constructor() { get hasQuery() {
super(...arguments); return !!this.searchQuery;
if (this.router && typeof this.router.on === 'function') {
this.router.on('routeDidChange', this.syncSearchQuery);
}
this.syncSearchQuery();
}
willDestroy() {
if (this.router && typeof this.router.off === 'function') {
this.router.off('routeDidChange', this.syncSearchQuery);
}
super.willDestroy(...arguments);
}
@action
syncSearchQuery() {
const qp =
this.mapUi.currentSearch || this.router?.currentRoute?.queryParams;
if (qp?.q) {
this.searchQuery = qp.q;
} else if (qp?.category) {
const category = POI_CATEGORIES.find((c) => c.id === qp.category);
this.searchQuery = category ? category.label : qp.category;
} else {
this.searchQuery = '';
}
}
get isSearching() {
// 1. If we are actively focusing/typing in the search box with a query, hide pills
if (this.mapUi.searchBoxHasFocus && this.searchQuery) {
return true;
}
// 2. If we are on the search route, check loading and results status
if (this.router?.currentRouteName === 'search') {
if (this.mapUi.loadingState) {
return false; // Keep pills visible while loading
}
return this.mapUi.searchResults && this.mapUi.searchResults.length > 0;
}
// 3. Fallback for integration tests (non-search route with a query)
if (this.router?.currentRouteName !== 'search' && this.searchQuery) {
return true;
}
return false;
} }
get showQuickSearch() { get showQuickSearch() {
@@ -110,7 +61,7 @@ export default class AppHeaderComponent extends Component {
</div> </div>
{{#if this.showQuickSearch}} {{#if this.showQuickSearch}}
<div class="header-center {{if this.isSearching 'searching'}}"> <div class="header-center {{if this.hasQuery 'searching'}}">
<CategoryChips @onSelect={{this.handleChipSelect}} /> <CategoryChips @onSelect={{this.handleChipSelect}} />
</div> </div>
{{/if}} {{/if}}
+2 -2
View File
@@ -3,11 +3,11 @@ import Icon from '#components/icon';
<template> <template>
{{! template-lint-disable no-nested-interactive }} {{! template-lint-disable no-nested-interactive }}
<div class="sidebar-header has-back-btn"> <div class="sidebar-header">
<button type="button" class="back-btn" {{on "click" @onBack}}> <button type="button" class="back-btn" {{on "click" @onBack}}>
<Icon @name="arrow-left" @size={{20}} @color="#333" /> <Icon @name="arrow-left" @size={{20}} @color="#333" />
</button> </button>
<h2 class="sidebar-header-text-centered">About</h2> <h2>About</h2>
<button type="button" class="close-btn" {{on "click" @onClose}}> <button type="button" class="close-btn" {{on "click" @onClose}}>
<Icon @name="x" @size={{20}} @color="#333" /> <Icon @name="x" @size={{20}} @color="#333" />
</button> </button>
+1 -1
View File
@@ -22,7 +22,7 @@ import iconRounded from '../../icons/icon-rounded.svg?raw';
<li> <li>
<button type="button" {{on "click" @onSavedPlaces}}> <button type="button" {{on "click" @onSavedPlaces}}>
<Icon @name="bookmark" @size={{20}} /> <Icon @name="bookmark" @size={{20}} />
<span>Collections</span> <span>Saved places</span>
</button> </button>
</li> </li>
<li> <li>
+2 -2
View File
@@ -21,11 +21,11 @@ export default class AppMenuSettings extends Component {
} }
<template> <template>
<div class="sidebar-header has-back-btn"> <div class="sidebar-header">
<button type="button" class="back-btn" {{on "click" @onBack}}> <button type="button" class="back-btn" {{on "click" @onBack}}>
<Icon @name="arrow-left" @size={{20}} @color="#333" /> <Icon @name="arrow-left" @size={{20}} @color="#333" />
</button> </button>
<h2 class="sidebar-header-text-centered">Settings</h2> <h2>Settings</h2>
<button type="button" class="close-btn" {{on "click" @onClose}}> <button type="button" class="close-btn" {{on "click" @onClose}}>
<Icon @name="x" @size={{20}} @color="#333" /> <Icon @name="x" @size={{20}} @color="#333" />
</button> </button>
-7
View File
@@ -1088,7 +1088,6 @@ export default class MapComponent extends Component {
const bbox = { minLat, minLon, maxLat, maxLon }; const bbox = { minLat, minLon, maxLat, maxLon };
this.mapUi.updateBounds(bbox); this.mapUi.updateBounds(bbox);
await this.storage.loadPlacesInBounds(bbox); await this.storage.loadPlacesInBounds(bbox);
if (this.isDestroying || this.isDestroyed) return;
this.nostrData.loadPlacesInBounds(bbox); this.nostrData.loadPlacesInBounds(bbox);
this.loadBookmarks(this.storage.placesInView); this.loadBookmarks(this.storage.placesInView);
@@ -1192,12 +1191,6 @@ export default class MapComponent extends Component {
return; return;
} }
if (this.mapUi.searchResults && this.mapUi.searchResults.length > 0) {
console.debug('Clearing active search and markers on map click');
this.router.transitionTo('index');
return;
}
// Require Zoom >= 17 for generic map searches // Require Zoom >= 17 for generic map searches
// This prevents accidental searches when interacting with the map at a high level // This prevents accidental searches when interacting with the map at a high level
const currentZoom = this.mapInstance.getView().getZoom(); const currentZoom = this.mapInstance.getView().getZoom();
+16 -37
View File
@@ -5,8 +5,6 @@ import { on } from '@ember/modifier';
import { fn } from '@ember/helper'; import { fn } from '@ember/helper';
import or from 'ember-truth-helpers/helpers/or'; import or from 'ember-truth-helpers/helpers/or';
import eq from 'ember-truth-helpers/helpers/eq'; import eq from 'ember-truth-helpers/helpers/eq';
import and from 'ember-truth-helpers/helpers/and';
import not from 'ember-truth-helpers/helpers/not';
import PlaceDetails from './place-details'; import PlaceDetails from './place-details';
import Icon from './icon'; import Icon from './icon';
import humanizeOsmTag from '../helpers/humanize-osm-tag'; import humanizeOsmTag from '../helpers/humanize-osm-tag';
@@ -163,11 +161,7 @@ export default class PlacesSidebar extends Component {
<template> <template>
<div class="sidebar"> <div class="sidebar">
<div <div class="sidebar-header {{if this.hasHeaderPhoto 'no-border'}}">
class="sidebar-header
{{if this.hasHeaderPhoto 'no-border'}}
{{if (and (not @selectedPlace) @onBack) 'has-back-btn'}}"
>
{{#if @selectedPlace}} {{#if @selectedPlace}}
<button <button
type="button" type="button"
@@ -176,31 +170,20 @@ export default class PlacesSidebar extends Component {
><Icon @name="arrow-left" @size={{20}} @color="#333" /></button> ><Icon @name="arrow-left" @size={{20}} @color="#333" /></button>
{{else}} {{else}}
{{#if @onBack}} {{#if @onBack}}
<button type="button" class="back-btn" {{on "click" @onBack}}><Icon <button
@name="arrow-left" type="button"
@size={{20}} class="back-btn"
@color="#333" {{on "click" @onBack}}
/></button> ><Icon @name="arrow-left" @size={{20}} @color="#333" /></button>
{{/if}} {{/if}}
{{#if @onBack}} {{#if @title}}
<h2 class="sidebar-header-text-centered"> <h2><Icon @name="bookmark" @size={{20}} @color="#333" />
<span class="sidebar-header-icon-wrapper"> {{@title}}</h2>
<Icon {{else if this.isNearbySearch}}
@name="bookmark"
@size={{20}}
@color={{or @color "#898989"}}
/>
</span>
{{@title}}
</h2>
{{else}}
{{#if this.isNearbySearch}}
<h2><Icon @name="target" @size={{20}} @color="#ea4335" /> <h2><Icon @name="target" @size={{20}} @color="#ea4335" />
Nearby</h2> Nearby</h2>
{{else}} {{else}}
<h2><Icon @name="search" @size={{20}} @color="#333" /> <h2><Icon @name="search" @size={{20}} @color="#333" /> Results</h2>
Results</h2>
{{/if}}
{{/if}} {{/if}}
{{/if}} {{/if}}
<button type="button" class="close-btn" {{on "click" @onClose}}><Icon <button type="button" class="close-btn" {{on "click" @onClose}}><Icon
@@ -217,11 +200,6 @@ export default class PlacesSidebar extends Component {
@onToggleSave={{this.toggleSave}} @onToggleSave={{this.toggleSave}}
@onSave={{this.updateBookmark}} @onSave={{this.updateBookmark}}
/> />
{{else}}
{{#if @isLoading}}
<div class="sidebar-loading">
<Icon @name="loading-ring" @size={{24}} @color="#898989" />
</div>
{{else}} {{else}}
{{#if @places}} {{#if @places}}
<ul class="places-list"> <ul class="places-list">
@@ -243,10 +221,12 @@ export default class PlacesSidebar extends Component {
{{humanizeOsmTag place.type}} {{humanizeOsmTag place.type}}
{{else if (eq place.source "photon")}} {{else if (eq place.source "photon")}}
{{place.description}} {{place.description}}
{{else if (getPlaceType place.osmTags)}}
{{getPlaceType place.osmTags}}
{{else}} {{else}}
Saved place {{#if place.osmTags}}
{{humanizeOsmTag (getPlaceType place.osmTags)}}
{{else if place.description}}
{{place.description}}
{{/if}}
{{/if}} {{/if}}
</div> </div>
</button> </button>
@@ -270,7 +250,6 @@ export default class PlacesSidebar extends Component {
Create new place Create new place
</button> </button>
{{/if}} {{/if}}
{{/if}}
</div> </div>
</div> </div>
</template> </template>
-118
View File
@@ -1,118 +0,0 @@
import Controller from '@ember/controller';
import { service } from '@ember/service';
import { action } from '@ember/object';
import { tracked } from '@glimmer/tracking';
import { task } from 'ember-concurrency';
function getPlaceTime(place) {
const dateVal = place.createdAt;
if (!dateVal) return 0;
if (typeof dateVal === 'number') {
return dateVal;
}
const parsed = Date.parse(dateVal);
return isNaN(parsed) ? 0 : parsed;
}
export default class ListsListController extends Controller {
@service router;
@service mapUi;
@service storage;
@tracked model;
@tracked loadedPlaces = [];
get listId() {
return this.model?.list_id;
}
loadPlacesTask = task({ restartable: true }, async (listId) => {
this.loadedPlaces = []; // Clear previous elements immediately to show fresh loader
try {
this.loadedPlaces = await this.storage.getPlacesInList(listId);
} catch (e) {
console.error('Failed to load places in list', listId, e);
this.loadedPlaces = [];
}
});
get scrollTop() {
return this.mapUi.getScrollPosition(`list-${this.listId}`);
}
get listColor() {
const list = this.storage.lists.find((l) => l.id === this.listId);
if (list && list.color) {
return list.color;
}
return getComputedStyle(document.documentElement)
.getPropertyValue('--default-list-color')
.trim();
}
get listTitle() {
const list = this.storage.lists.find((l) => l.id === this.listId);
return list ? list.title : 'Collections';
}
get places() {
const currentList = this.storage.lists.find((l) => l.id === this.listId);
const placeRefsIds = new Set(
currentList?.placeRefs?.map((ref) => ref.id) || []
);
// Filter live tracked savedPlaces that are in this list
const livePlaces = this.storage.savedPlaces.filter((p) =>
placeRefsIds.has(p.id)
);
const merged = [];
const seen = new Set();
// Process live state first to reflect deletions/edits immediately
livePlaces.forEach((p) => {
merged.push(p);
seen.add(p.id);
});
// Supplement with any background-fetched places that are still valid but not in live state yet
this.loadedPlaces.forEach((p) => {
if (placeRefsIds.has(p.id) && !seen.has(p.id)) {
merged.push(p);
seen.add(p.id);
}
});
return merged.sort((a, b) => getPlaceTime(b) - getPlaceTime(a));
}
@action
selectPlace(place) {
if (place) {
const sidebarContent = document.querySelector('.sidebar-content');
if (sidebarContent) {
this.mapUi.saveScrollPosition(
`list-${this.listId}`,
sidebarContent.scrollTop
);
}
this.mapUi.returnToRoute = {
name: 'lists.list',
model: this.listId,
};
this.mapUi.showSidebar();
this.mapUi.preventNextZoom = true;
this.router.transitionTo('place', place);
}
}
@action
close() {
this.router.transitionTo('index');
}
@action
backToLists() {
this.router.transitionTo('lists.index');
}
}
+1 -20
View File
@@ -20,26 +20,7 @@ export default class SearchController extends Controller {
category = null; category = null;
fetchResultsTask = task({ restartable: true }, async (params) => { fetchResultsTask = task({ restartable: true }, async (params) => {
// 1. Check if the incoming parameters match our currently loaded search // Hide sidebar and clear previous results immediately to signal a new search
const isSameSearch =
this.mapUi.currentSearch &&
params.q === this.mapUi.currentSearch.q &&
params.category === this.mapUi.currentSearch.category &&
params.lat === this.mapUi.currentSearch.lat &&
params.lon === this.mapUi.currentSearch.lon;
const hasResults =
this.mapUi.searchResults && this.mapUi.searchResults.length > 0;
// 2. If it's a back navigation to the exact same search, resolve instantly with no animation
if (isSameSearch && hasResults) {
if (this.mapUi.isSidebarVisible) {
this.mapUi.showSidebar();
}
return;
}
// 3. Otherwise, this is a brand new search: hide the sidebar and clear previous results immediately to signal a new search
this.mapUi.hideSidebar(); this.mapUi.hideSidebar();
this.mapUi.clearSearchResults(); this.mapUi.clearSearchResults();
+8 -17
View File
@@ -4,23 +4,14 @@ import { service } from '@ember/service';
export default class ListsListRoute extends Route { export default class ListsListRoute extends Route {
@service storage; @service storage;
model(params) { async model(params) {
// Resolve instantly so transition happens in 0ms! const listId = params.list_id;
return { list_id: params.list_id }; try {
} const places = await this.storage.getPlacesInList(listId);
return { listId, places };
setupController(controller, model) { } catch (e) {
console.debug('DEBUG: setupController controller is:', controller); console.error('Failed to load places in list', listId, e);
console.debug( return { listId, places: [] };
'DEBUG: controller.loadPlacesTask is:',
controller?.loadPlacesTask
);
controller.model = model;
super.setupController(controller, model);
if (controller && controller.loadPlacesTask) {
controller.loadPlacesTask.perform(model.list_id);
} else {
console.error('DEBUG: ERROR! controller.loadPlacesTask is undefined!');
} }
} }
} }
+8 -55
View File
@@ -115,6 +115,8 @@ body {
display: flex; display: flex;
align-items: center; align-items: center;
grid-area: search; grid-area: search;
/* Ensure it sits at the start of its grid area */
justify-self: start; justify-self: start;
width: 100%; width: 100%;
} }
@@ -128,6 +130,7 @@ body {
@media (width > 768px) { @media (width > 768px) {
.header-left { .header-left {
/* Desktop: Ensure minimum width for search box so it's not squeezed */
min-width: 300px; min-width: 300px;
max-width: 350px; max-width: 350px;
} }
@@ -140,6 +143,8 @@ body {
.header-center { .header-center {
grid-area: chips; grid-area: chips;
/* Desktop: Center the chips block in the available space */
display: flex; display: flex;
justify-content: center; justify-content: center;
min-width: 0; /* Allow shrinking */ min-width: 0; /* Allow shrinking */
@@ -154,6 +159,7 @@ body {
} }
@media (width <= 768px) { @media (width <= 768px) {
/* No need to reset min-width/max-width since they are only set in media query above */
.header-center { .header-center {
width: 100%; width: 100%;
overflow: hidden; overflow: hidden;
@@ -480,13 +486,11 @@ body {
} }
.sidebar-header { .sidebar-header {
height: 56px; /* Strictly enforce identical vertical height */ padding: 1rem;
padding: 0 1rem; /* Keep horizontal padding, remove vertical padding */
border-bottom: 1px solid var(--divider-color); border-bottom: 1px solid var(--divider-color);
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
box-sizing: border-box; /* Guarantee strict height boundaries */
} }
.sidebar-header.no-border { .sidebar-header.no-border {
@@ -495,7 +499,7 @@ body {
.sidebar-header h2 { .sidebar-header h2 {
margin: 0; margin: 0;
font-size: 1.1rem; font-size: 1.2rem;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.5rem;
@@ -1424,9 +1428,6 @@ button.create-place {
border-top-left-radius: 16px; border-top-left-radius: 16px;
border-top-right-radius: 16px; border-top-right-radius: 16px;
inset: auto 0 0; inset: auto 0 0;
}
.sidebar-opening .sidebar {
animation: sidebar-slide-up-bottom 0.18s cubic-bezier(0.16, 1, 0.3, 1) animation: sidebar-slide-up-bottom 0.18s cubic-bezier(0.16, 1, 0.3, 1)
forwards; forwards;
} }
@@ -2213,51 +2214,3 @@ button.create-place {
white-space: nowrap; white-space: nowrap;
flex-shrink: 0; flex-shrink: 0;
} }
/* Centered layout when back button is present */
.sidebar-header.has-back-btn {
position: relative;
justify-content: center; /* Center horizontally */
}
/* Absolute positioning for buttons in centered header */
.sidebar-header.has-back-btn .back-btn {
position: absolute;
left: 1rem;
z-index: 10;
}
.sidebar-header.has-back-btn .close-btn {
position: absolute;
right: 1rem;
z-index: 10;
}
/* Centralized Title text */
.sidebar-header-text-centered {
position: relative;
margin: 0;
font-size: 1.2rem;
font-weight: bold;
display: inline-flex;
align-items: center;
justify-content: center;
text-align: center;
max-width: 60%;
}
.sidebar-header-icon-wrapper {
position: absolute;
right: 100%;
margin-right: 0.5rem;
display: flex;
align-items: center;
}
/* Sidebar Loading State */
.sidebar-loading {
display: flex;
align-items: center;
justify-content: center;
padding: 4rem 1rem;
}
+3 -8
View File
@@ -2,9 +2,12 @@ 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 AppMenu from '#components/app-menu/index';
import Toast from '#components/toast'; import Toast from '#components/toast';
import { service } from '@ember/service'; import { service } from '@ember/service';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object'; import { action } from '@ember/object';
import { or } from 'ember-truth-helpers';
import { on } from '@ember/modifier'; import { on } from '@ember/modifier';
export default class ApplicationComponent extends Component { export default class ApplicationComponent extends Component {
@@ -55,14 +58,6 @@ export default class ApplicationComponent extends Component {
this.mapUi.hideSidebar(); this.mapUi.hideSidebar();
if (name === 'menu' || name.startsWith('lists')) { if (name === 'menu' || name.startsWith('lists')) {
this.router.transitionTo('index'); this.router.transitionTo('index');
} else if (name === 'place') {
if (this.mapUi.returnToSearch && this.mapUi.currentSearch) {
this.router.transitionTo('search', {
queryParams: this.mapUi.currentSearch,
});
} else {
this.router.transitionTo('index');
}
} }
} }
} }
+7 -1
View File
@@ -1 +1,7 @@
<template>{{outlet}}</template> import Component from '@glimmer/component';
export default class ListsTemplate extends Component {
<template>
{{outlet}}
</template>
}
+4 -21
View File
@@ -4,22 +4,12 @@ import { action } from '@ember/object';
import { fn } from '@ember/helper'; import { fn } from '@ember/helper';
import { on } from '@ember/modifier'; import { on } from '@ember/modifier';
import Icon from '#components/icon'; import Icon from '#components/icon';
import { htmlSafe } from '@ember/template';
export default class ListsIndexTemplate extends Component { export default class ListsIndexTemplate extends Component {
@service storage; @service storage;
@service router; @service router;
@service mapUi; @service mapUi;
styleFor(color) {
const finalColor =
color ||
getComputedStyle(document.documentElement)
.getPropertyValue('--default-list-color')
.trim();
return htmlSafe(`background-color: ${finalColor}`);
}
@action @action
selectList(listId) { selectList(listId) {
this.router.transitionTo('lists.list', listId); this.router.transitionTo('lists.list', listId);
@@ -38,16 +28,11 @@ export default class ListsIndexTemplate extends Component {
<template> <template>
{{#if this.mapUi.isSidebarVisible}} {{#if this.mapUi.isSidebarVisible}}
<div class="sidebar"> <div class="sidebar">
<div class="sidebar-header has-back-btn"> <div class="sidebar-header">
<button type="button" class="back-btn" {{on "click" this.backToMenu}}> <button type="button" class="back-btn" {{on "click" this.backToMenu}}>
<Icon @name="arrow-left" @size={{20}} @color="#333" /> <Icon @name="arrow-left" @size={{20}} @color="#333" />
</button> </button>
<h2 class="sidebar-header-text-centered"> <h2><Icon @name="bookmark" @size={{20}} @color="#333" /> Saved places</h2>
<span class="sidebar-header-icon-wrapper">
<Icon @name="bookmark" @size={{20}} @color="#898989" />
</span>
Collections
</h2>
<button type="button" class="close-btn" {{on "click" this.close}}> <button type="button" class="close-btn" {{on "click" this.close}}>
<Icon @name="x" @size={{20}} @color="#333" /> <Icon @name="x" @size={{20}} @color="#333" />
</button> </button>
@@ -63,17 +48,15 @@ export default class ListsIndexTemplate extends Component {
{{on "click" (fn this.selectList list.id)}} {{on "click" (fn this.selectList list.id)}}
> >
<div class="lists-index-item-left"> <div class="lists-index-item-left">
{{! template-lint-disable no-inline-styles }}
<span <span
class="list-color-dot" class="list-color-dot"
style={{this.styleFor list.color}} style="background-color: {{list.color}}"
></span> ></span>
<div class="lists-index-name">{{list.title}}</div> <div class="lists-index-name">{{list.title}}</div>
</div> </div>
<div class="lists-index-count"> <div class="lists-index-count">
{{#if list.placeRefs.length}} {{#if list.placeRefs.length}}
{{list.placeRefs.length}} {{list.placeRefs.length}} places
places
{{else}} {{else}}
empty empty
{{/if}} {{/if}}
+84 -9
View File
@@ -1,16 +1,91 @@
import Component from '@glimmer/component';
import PlacesSidebar from '#components/places-sidebar'; import PlacesSidebar from '#components/places-sidebar';
import { service } from '@ember/service';
import { action } from '@ember/object';
export default class ListsListTemplate extends Component {
@service router;
@service mapUi;
@service storage;
get listId() {
return this.args.model?.listId;
}
get scrollTop() {
return this.mapUi.getScrollPosition(`list-${this.listId}`);
}
get listTitle() {
const list = this.storage.lists.find((l) => l.id === this.listId);
return list ? list.title : 'Saved places';
}
get places() {
const modelPlaces = this.args.model?.places || [];
const currentList = this.storage.lists.find((l) => l.id === this.listId);
const placeRefsIds = new Set(currentList?.placeRefs?.map((ref) => ref.id) || []);
// Filter live tracked savedPlaces that are in this list
const livePlaces = this.storage.savedPlaces.filter((p) => placeRefsIds.has(p.id));
const merged = [];
const seen = new Set();
// Process live state first to reflect deletions/edits immediately
livePlaces.forEach((p) => {
merged.push(p);
seen.add(p.id);
});
// Supplement with any model-fetched places that are still valid but not in live state yet
modelPlaces.forEach((p) => {
if (placeRefsIds.has(p.id) && !seen.has(p.id)) {
merged.push(p);
seen.add(p.id);
}
});
return merged;
}
@action
selectPlace(place) {
if (place) {
const sidebarContent = document.querySelector('.sidebar-content');
if (sidebarContent) {
this.mapUi.saveScrollPosition(`list-${this.listId}`, sidebarContent.scrollTop);
}
this.mapUi.returnToRoute = {
name: 'lists.list',
model: this.listId,
};
this.mapUi.showSidebar();
this.mapUi.preventNextZoom = true;
this.router.transitionTo('place', place);
}
}
@action
close() {
this.router.transitionTo('index');
}
@action
backToLists() {
this.router.transitionTo('lists.index');
}
<template> <template>
{{#if @controller.mapUi.isSidebarVisible}} {{#if this.mapUi.isSidebarVisible}}
<PlacesSidebar <PlacesSidebar
@places={{@controller.places}} @places={{this.places}}
@title={{@controller.listTitle}} @title={{this.listTitle}}
@color={{@controller.listColor}} @scrollTop={{this.scrollTop}}
@scrollTop={{@controller.scrollTop}} @onSelect={{this.selectPlace}}
@isLoading={{@controller.loadPlacesTask.isRunning}} @onClose={{this.close}}
@onSelect={{@controller.selectPlace}} @onBack={{this.backToLists}}
@onClose={{@controller.close}}
@onBack={{@controller.backToLists}}
/> />
{{/if}} {{/if}}
</template> </template>
}
+3 -1
View File
@@ -11,5 +11,7 @@ export default class MenuTemplate extends Component {
this.router.transitionTo('index'); this.router.transitionTo('index');
} }
<template><AppMenu @onClose={{this.close}} /></template> <template>
<AppMenu @onClose={{this.close}} />
</template>
} }
-7
View File
@@ -104,13 +104,6 @@ export default class PlaceTemplate extends Component {
close() { close() {
this.mapUi.clearSelection(); this.mapUi.clearSelection();
this.mapUi.hideSidebar(); this.mapUi.hideSidebar();
if (this.mapUi.returnToSearch && this.mapUi.currentSearch) {
this.router.transitionTo('search', {
queryParams: this.mapUi.currentSearch,
});
} else {
this.router.transitionTo('index');
}
} }
<template> <template>
+2 -2
View File
@@ -1,6 +1,6 @@
{ {
"name": "marco", "name": "marco",
"version": "1.25.1", "version": "1.24.0",
"private": true, "private": true,
"description": "Unhosted maps app", "description": "Unhosted maps app",
"repository": { "repository": {
@@ -52,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.3.0", "@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",
+5 -5
View File
@@ -88,8 +88,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.3.0 specifier: ~1.2.1
version: 1.3.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)
@@ -1468,8 +1468,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.3.0': '@remotestorage/module-places@1.2.1':
resolution: {integrity: sha512-VM0CqkIP6IBEpjqJ2DyTrGDOQXc73aXoAFDLIoME5Lo033uTitgn+qKgTGPK/lD4H92mk1+D3W88ECtf9v3mWw==} 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==}
@@ -7439,7 +7439,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.3.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
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -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-DNM-h3Gw.js"></script> <script type="module" crossorigin src="/assets/main-CLZV93ov.js"></script>
<link rel="stylesheet" crossorigin href="/assets/main-BGF-Udec.css"> <link rel="stylesheet" crossorigin href="/assets/main-COnSXoPt.css">
</head> </head>
<body> <body>
<div id="modal-portal"></div> <div id="modal-portal"></div>
-219
View File
@@ -1,219 +0,0 @@
import { module, test } from 'qunit';
import { visit, currentURL, click, waitFor } from '@ember/test-helpers';
import { setupApplicationTest } from 'marco/tests/helpers';
import Service from '@ember/service';
class MockOsmService extends Service {
async fetchOsmObject() {
return null;
}
}
class MockStorageService extends Service {
initialSyncDone = true;
savedPlaces = [
{
id: 'place-123',
title: 'Mountain Trail',
geohash: 'u33dc0',
osmTags: { name: 'Mountain Trail' },
},
];
lists = [
{
id: 'to-go',
title: 'Want to go',
color: '#2e9e4f',
placeRefs: [{ id: 'place-123', geohash: 'u33dc0' }],
},
{ id: 'to-do', title: 'To do', color: '#2a7fff', placeRefs: [] },
];
findPlaceById(id) {
if (id === 'place-123') {
return this.savedPlaces[0];
}
return null;
}
isPlaceSaved() {
return true;
}
loadPlacesInBounds() {
return [];
}
getPlacesInList(listId) {
if (listId === 'to-go') {
return Promise.resolve([this.savedPlaces[0]]);
}
return Promise.resolve([]);
}
rs = {
on: () => {},
};
}
module('Acceptance | collections navigation', function (hooks) {
setupApplicationTest(hooks);
hooks.beforeEach(function () {
this.owner.register('service:osm', MockOsmService);
this.owner.register('service:storage', MockStorageService);
});
test('navigating through the collections menu hierarchy, viewing list places, and going back', async function (assert) {
// 1. Visit Home Map
await visit('/');
assert.strictEqual(currentURL(), '/');
// 2. Open the App Menu overlay
await click('.menu-btn-integrated');
assert.dom('.sidebar.app-menu-pane').exists('App menu sidebar is open');
assert
.dom('.app-menu')
.includesText('Collections', 'Menu contains Collections link');
// 3. Transition to Collections Index (List of lists)
await click(document.querySelectorAll('.app-menu button')[0]); // Click "Collections"
assert.strictEqual(currentURL(), '/lists', 'Transitions to /lists index');
assert
.dom('.sidebar-header-text-centered')
.includesText('Collections', 'Header is centered and titled Collections');
assert
.dom('.lists-index-item')
.exists({ count: 2 }, 'Renders our 2 mocked list items');
// 4. Transition to a specific list (Want to go)
await click(document.querySelectorAll('.lists-index-item')[0]); // Click "Want to go"
assert.strictEqual(
currentURL(),
'/lists/to-go',
'Transitions instantly to /lists/to-go'
);
// 5. Verify background loading spinner shows up, then results populate
await waitFor('.places-list');
assert
.dom('.places-list .place-name')
.hasText('Mountain Trail', 'Renders the saved place from the list');
assert
.dom('.places-list .place-type')
.hasText('Saved place', 'Place type displays Saved place correctly');
// 6. Click back button in collection list header
await click('.sidebar-header .back-btn');
assert.strictEqual(currentURL(), '/lists', 'Goes back to /lists index');
// 7. Click back button in collections index header
await click('.sidebar-header .back-btn');
assert.strictEqual(currentURL(), '/menu', 'Goes back to main menu route');
// 8. Close sidebar
await click('.sidebar-header .close-btn');
assert.strictEqual(currentURL(), '/', 'Sidebar closed and returned home');
});
test('clicking a place inside a collection sets returnToRoute and returns gracefully on back click', async function (assert) {
await visit('/lists/to-go');
await waitFor('.places-list');
// Click on the place item to view details
await click('.place-item');
assert.ok(
currentURL().includes('/place/place-123'),
'Transitions to place details route'
);
// Click back from place details
await click('.back-btn');
assert.strictEqual(
currentURL(),
'/lists/to-go',
'Returns gracefully back to lists/to-go list view'
);
});
test('places inside a collection are sorted by createdAt descending', async function (assert) {
class SortedMockStorageService extends Service {
initialSyncDone = true;
savedPlaces = [
{
id: 'place-oldest',
title: 'Oldest Place',
geohash: 'u33dc0',
createdAt: '2023-01-01T12:00:00.000Z',
osmTags: { name: 'Oldest Place' },
},
{
id: 'place-newest',
title: 'Newest Place',
geohash: 'u33dc0',
createdAt: '2023-01-03T12:00:00.000Z',
osmTags: { name: 'Newest Place' },
},
{
id: 'place-middle',
title: 'Middle Place',
geohash: 'u33dc0',
createdAt: '2023-01-02T12:00:00.000Z',
updatedAt: '2023-01-04T12:00:00.000Z',
osmTags: { name: 'Middle Place' },
},
];
lists = [
{
id: 'to-go',
title: 'Want to go',
color: '#2e9e4f',
placeRefs: [
{ id: 'place-oldest', geohash: 'u33dc0' },
{ id: 'place-newest', geohash: 'u33dc0' },
{ id: 'place-middle', geohash: 'u33dc0' },
],
},
];
findPlaceById(id) {
return this.savedPlaces.find((p) => p.id === id) || null;
}
isPlaceSaved() {
return true;
}
loadPlacesInBounds() {
return [];
}
getPlacesInList(listId) {
if (listId === 'to-go') {
return Promise.resolve(this.savedPlaces);
}
return Promise.resolve([]);
}
rs = {
on: () => {},
};
}
this.owner.unregister('service:storage');
this.owner.register('service:storage', SortedMockStorageService);
await visit('/lists/to-go');
await waitFor('.places-list');
const placeNames = Array.from(
document.querySelectorAll('.places-list .place-name')
).map((el) => el.textContent.trim());
assert.deepEqual(
placeNames,
['Newest Place', 'Middle Place', 'Oldest Place'],
'Places are ordered by createdAt in descending order'
);
});
});
+56 -2
View File
@@ -2,6 +2,7 @@ import { module, test } from 'qunit';
import { visit, currentURL, waitFor, triggerEvent } from '@ember/test-helpers'; import { visit, currentURL, waitFor, triggerEvent } 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';
module('Acceptance | map search reset', function (hooks) { module('Acceptance | map search reset', function (hooks) {
setupApplicationTest(hooks); setupApplicationTest(hooks);
@@ -16,10 +17,58 @@ module('Acceptance | map search reset', function (hooks) {
'marco:map-view', 'marco:map-view',
JSON.stringify(highZoomState) JSON.stringify(highZoomState)
); );
// Stub window.fetch using Sinon
// We want to intercept map style requests and let everything else through
this.fetchStub = sinon.stub(window, 'fetch');
this.fetchStub.callsFake(async (input, init) => {
let url = input;
if (typeof input === 'object' && input !== null && 'url' in input) {
url = input.url;
}
if (
typeof url === 'string' &&
url.includes('tiles.openfreemap.org/styles/liberty')
) {
return {
ok: true,
status: 200,
json: async () => ({
version: 8,
name: 'Liberty',
sources: {
openmaptiles: {
type: 'vector',
url: 'https://tiles.openfreemap.org/planet',
},
},
layers: [
{
id: 'background',
type: 'background',
paint: {
'background-color': '#123456',
},
},
],
glyphs:
'https://tiles.openfreemap.org/fonts/{fontstack}/{range}.pbf',
sprite: 'https://tiles.openfreemap.org/sprites/liberty',
}),
};
}
// Pass through to the original implementation
return this.fetchStub.wrappedMethod(input, init);
});
}); });
hooks.afterEach(function () { hooks.afterEach(function () {
window.localStorage.removeItem('marco:map-view'); window.localStorage.removeItem('marco:map-view');
// Restore the original fetch
this.fetchStub.restore();
}); });
test('clicking the map clears the category search parameter', async function (assert) { test('clicking the map clears the category search parameter', async function (assert) {
@@ -101,7 +150,7 @@ module('Acceptance | map search reset', function (hooks) {
'Should have stayed on the search route with markers intact' 'Should have stayed on the search route with markers intact'
); );
// Second Click (Clear search and markers) // Second Click (Start new search)
// Click slightly differently to ensure fresh event // Click slightly differently to ensure fresh event
await triggerEvent(canvas, 'pointerdown', { await triggerEvent(canvas, 'pointerdown', {
clientX: 250, clientX: 250,
@@ -125,6 +174,11 @@ module('Acceptance | map search reset', function (hooks) {
// 3. Wait for transition // 3. Wait for transition
await new Promise((r) => setTimeout(r, 1000)); await new Promise((r) => setTimeout(r, 1000));
assert.strictEqual(currentURL(), '/', 'Should have transitioned to index'); const newUrl = currentURL();
assert.notOk(
newUrl.includes('category=coffee'),
`New URL ${newUrl} should not contain category param`
);
assert.ok(newUrl.includes('/search'), 'Should be on search route');
}); });
}); });
+2 -17
View File
@@ -85,7 +85,7 @@ module('Acceptance | navigation', function (hooks) {
); );
}); });
test('closing the sidebar transitions back to search route when opened from search results', async function (assert) { test('closing the sidebar resets the returnToSearch flag', async function (assert) {
const mapUi = this.owner.lookup('service:map-ui'); const mapUi = this.owner.lookup('service:map-ui');
await visit('/search?lat=1&lon=1'); await visit('/search?lat=1&lon=1');
@@ -97,22 +97,7 @@ module('Acceptance | navigation', function (hooks) {
await click('.close-btn'); await click('.close-btn');
assert.dom('.sidebar').doesNotExist('Sidebar should be closed'); assert.dom('.sidebar').doesNotExist('Sidebar should be closed');
assert.strictEqual( assert.ok(currentURL().includes('/place/'), 'Remains on place route');
currentURL(),
'/search?lat=1&lon=1',
'Should transition back to search route'
);
});
test('closing the sidebar when visiting a place directly transitions to index', async function (assert) {
await visit('/place/osm:node:123');
assert.ok(currentURL().includes('/place/'), 'Visited place directly');
// Click the Close (X) button
await click('.close-btn');
assert.dom('.sidebar').doesNotExist('Sidebar should be closed');
assert.strictEqual(currentURL(), '/', 'Should transition back to index');
}); });
test('navigating directly to place and back closes sidebar', async function (assert) { test('navigating directly to place and back closes sidebar', async function (assert) {
-73
View File
@@ -169,77 +169,4 @@ module('Acceptance | search loading', function (hooks) {
// Verify we are back on index (or at least query is gone) // Verify we are back on index (or at least query is gone)
assert.strictEqual(currentURL(), '/', 'Navigated to index'); assert.strictEqual(currentURL(), '/', 'Navigated to index');
}); });
test('quick search pills visibility during category search transition', async function (assert) {
const mapUi = this.owner.lookup('service:map-ui');
mapUi.currentZoom = 15;
// Seed localStorage with a high zoom level to ensure quick search buttons show
const highZoomState = {
center: [13.4, 52.5],
zoom: 18,
};
window.localStorage.setItem(
'marco:map-view',
JSON.stringify(highZoomState)
);
try {
// Make sure quick search buttons setting is enabled
const settings = this.owner.lookup('service:settings');
settings.showQuickSearchButtons = true;
// 1. Visit slowly loading category search
const catPromise = visit('/search?category=slow_category&lat=1&lon=1');
await new Promise((r) => setTimeout(r, 10));
// Verify loading state is set and pills are visible (i.e. header-center does NOT have .searching)
assert.ok(mapUi.loadingState, 'Search is loading');
assert
.dom('.header-center')
.doesNotHaveClass(
'searching',
'Pills remain visible while search is loading'
);
// Resolve the promise with empty results
osmResolve();
await catPromise;
await settled();
// Verify search completed and since results are empty, pills are still visible
assert.strictEqual(mapUi.searchResults.length, 0, 'No results found');
assert
.dom('.header-center')
.doesNotHaveClass(
'searching',
'Pills remain visible after search completes with no results'
);
// 2. Now simulate a fast category search that returns results
const osmService = this.owner.lookup('service:osm');
osmService.getCategoryPois = async () => [
{
title: 'Latte Art Cafe',
lat: 1,
lon: 1,
osmId: '101',
osmType: 'N',
},
];
await visit('/search?category=coffee&lat=1&lon=1');
// Verify search completed with results, so pills are hidden
assert.ok(mapUi.searchResults.length > 0, 'Results found');
assert
.dom('.header-center')
.hasClass(
'searching',
'Pills are hidden after search completes with results'
);
} finally {
window.localStorage.removeItem('marco:map-view');
}
});
}); });
-72
View File
@@ -270,76 +270,4 @@ module('Acceptance | search', function (hooks) {
.hasText('Search request failed. Please try again.'); .hasText('Search request failed. Please try again.');
assert.dom('.places-sidebar').doesNotExist('Results panel should not open'); assert.dom('.places-sidebar').doesNotExist('Results panel should not open');
}); });
test('search box query synchronized with active route query parameters', async function (assert) {
// Mock Osm Service
class MockOsmService extends Service {
async getCategoryPois() {
return [];
}
}
this.owner.register('service:osm', MockOsmService);
// Mock Photon Service
class MockPhotonService extends Service {
async search() {
return [];
}
}
this.owner.register('service:photon', MockPhotonService);
// Mock Storage Service
class MockStorageService extends Service {
savedPlaces = [];
findPlaceById() {
return null;
}
isPlaceSaved() {
return false;
}
rs = { on: () => {} };
placesInView = [];
loadPlacesInBounds() {
return Promise.resolve();
}
}
this.owner.register('service:storage', MockStorageService);
// Mock Map Service
class MockMapService extends Service {
getBounds() {
return {
minLat: 52.5,
minLon: 13.4,
maxLat: 52.6,
maxLon: 13.5,
};
}
}
this.owner.register('service:map', MockMapService);
// 1. Visit a search URL directly
await visit('/search?q=Berlin');
assert
.dom('.search-input')
.hasValue(
'Berlin',
'Search input is populated with search term on direct load'
);
// 2. Visit a category search URL
await visit('/search?category=coffee&lat=52.52&lon=13.405');
assert
.dom('.search-input')
.hasValue(
'Coffee',
'Search input is populated with mapped category label'
);
// 3. Go back to index
await visit('/');
assert
.dom('.search-input')
.hasValue('', 'Search input is cleared on transitioning to index');
});
}); });
-87
View File
@@ -4,92 +4,6 @@ import {
setupTest as upstreamSetupTest, setupTest as upstreamSetupTest,
} from 'ember-qunit'; } from 'ember-qunit';
import { setupNostrMocks } from './mock-nostr'; import { setupNostrMocks } from './mock-nostr';
import sinon from 'sinon';
function setupMapStyleMocks(hooks) {
hooks.beforeEach(function () {
// Stub window.fetch to capture map-style assets before they hit the network
this.fetchStub = sinon.stub(window, 'fetch');
this.fetchStub.callsFake(async (input, init) => {
let url = input;
if (typeof input === 'object' && input !== null && 'url' in input) {
url = input.url;
}
if (typeof url === 'string' && url.includes('tiles.openfreemap.org')) {
// A. Mock Style Sheet
if (url.includes('/styles/liberty')) {
return {
ok: true,
status: 200,
json: async () => ({
version: 8,
name: 'Mock Style',
sources: {
openmaptiles: {
type: 'vector',
tiles: [], // Empty tiles list prevents any map tile fetching completely!
},
},
sprite: 'https://tiles.openfreemap.org/sprites/liberty',
glyphs:
'https://tiles.openfreemap.org/fonts/{fontstack}/{range}.pbf',
layers: [
{
id: 'background',
type: 'background',
paint: { 'background-color': '#f8f9fa' },
},
],
}),
};
}
// B. Mock Sprite Atlas JSON
if (url.endsWith('.json') && url.includes('/sprites/')) {
return {
ok: true,
status: 200,
json: async () => ({}), // Empty sprite dictionary
};
}
// C. Mock Sprite Atlas PNG (Returns a valid 1x1 transparent PNG)
if (url.endsWith('.png') && url.includes('/sprites/')) {
const bytes = new Uint8Array([
137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13, 73, 72, 68, 82, 0, 0,
0, 1, 0, 0, 0, 1, 8, 6, 0, 0, 0, 31, 21, 196, 137, 0, 0, 0, 11, 73,
68, 65, 84, 120, 156, 99, 96, 0, 0, 0, 2, 0, 1, 226, 33, 188, 51, 0,
0, 0, 0, 73, 69, 78, 68, 174, 66, 96, 130,
]);
const blob = new Blob([bytes], { type: 'image/png' });
return {
ok: true,
status: 200,
blob: async () => blob,
};
}
// Catch-all mock for other openfreemap endpoints
return {
ok: true,
status: 200,
json: async () => ({}),
};
}
// Pass through to original fetch (e.g. Photon results, local mock APIs)
return this.fetchStub.wrappedMethod(input, init);
});
});
hooks.afterEach(function () {
if (this.fetchStub && typeof this.fetchStub.restore === 'function') {
this.fetchStub.restore();
}
});
}
// This file exists to provide wrappers around ember-qunit's // This file exists to provide wrappers around ember-qunit's
// test setup functions. This way, you can easily extend the setup that is // test setup functions. This way, you can easily extend the setup that is
@@ -98,7 +12,6 @@ function setupMapStyleMocks(hooks) {
function setupApplicationTest(hooks, options) { function setupApplicationTest(hooks, options) {
upstreamSetupApplicationTest(hooks, options); upstreamSetupApplicationTest(hooks, options);
setupNostrMocks(hooks); setupNostrMocks(hooks);
setupMapStyleMocks(hooks);
// Additional setup for application tests can be done here. // Additional setup for application tests can be done here.
// //