Merge pull request #1223 from pietervdvn/fix/1219

Fix/1219
This commit is contained in:
Pieter Vander Vennet 2023-01-05 01:12:31 +01:00 committed by GitHub
commit a2a4071022
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
18 changed files with 276 additions and 134 deletions

View file

@ -45,20 +45,6 @@ export default class SelectedFeatureHandler {
const self = this
hash.addCallback(() => self.setSelectedElementFromHash())
state.featurePipeline?.newDataLoadedSignal?.addCallbackAndRunD((_) => {
// New data was loaded. In initial startup, the hash might be set (via the URL) but might not be selected yet
if (hash.data === undefined || SelectedFeatureHandler._no_trigger_on.has(hash.data)) {
// This is an invalid hash anyway
return
}
if (state.selectedElement.data !== undefined) {
// We already have something selected
return
}
self.setSelectedElementFromHash()
})
this.initialLoad()
}

View file

@ -62,7 +62,7 @@ export class OsmConnection {
private readonly _singlePage: boolean
private isChecking = false
constructor(options: {
constructor(options?: {
dryRun?: UIEventSource<boolean>
fakeUser?: false | boolean
oauth_token?: UIEventSource<string>
@ -71,6 +71,7 @@ export class OsmConnection {
osmConfiguration?: "osm" | "osm-test"
attemptLogin?: true | boolean
}) {
options = options ?? {}
this.fakeUser = options.fakeUser ?? false
this._singlePage = options.singlePage ?? true
this._oauth_config =

View file

@ -230,10 +230,12 @@ export abstract class Store<T> {
const newSource = new UIEventSource<T>(this.data)
const self = this
this.addCallback((latestData) => {
window.setTimeout(() => {
if (this.data == latestData) {
// compare by reference
if (self.data == latestData) {
// compare by reference.
// Note that 'latestData' and 'self.data' are both from the same UIEVentSource, but both are dereferenced at a different time
newSource.setData(latestData)
}
}, millisToStabilize)

View file

@ -414,6 +414,7 @@ export default class MinimapImplementation extends BaseUIElement implements Mini
map.on("contextmenu", function (e) {
// @ts-ignore
lastClickLocation?.setData({ lat: e.latlng.lat, lon: e.latlng.lng })
map.setZoom(map.getZoom() + 1)
})
}

View file

@ -37,12 +37,13 @@ export default class FullWelcomePaneWithTabs extends ScrollableFullScreen {
featurePipeline: FeaturePipeline
backgroundLayer: UIEventSource<BaseLayer>
filteredLayers: UIEventSource<FilteredLayer[]>
} & UserRelatedState
} & UserRelatedState,
guistate?: { userInfoIsOpened: UIEventSource<boolean> }
) {
const layoutToUse = state.layoutToUse
super(
() => layoutToUse.title.Clone(),
() => FullWelcomePaneWithTabs.GenerateContents(state, currentTab, isShown),
() => FullWelcomePaneWithTabs.GenerateContents(state, currentTab, isShown, guistate),
"welcome",
isShown
)
@ -60,12 +61,13 @@ export default class FullWelcomePaneWithTabs extends ScrollableFullScreen {
filteredLayers: UIEventSource<FilteredLayer[]>
} & UserRelatedState,
isShown: UIEventSource<boolean>,
currentTab: UIEventSource<number>
currentTab: UIEventSource<number>,
guistate?: { userInfoIsOpened: UIEventSource<boolean> }
): { header: string | BaseUIElement; content: BaseUIElement }[] {
const tabs: { header: string | BaseUIElement; content: BaseUIElement }[] = [
{
header: `<img src='${state.layoutToUse.icon}'>`,
content: new ThemeIntroductionPanel(isShown, currentTab, state),
content: new ThemeIntroductionPanel(isShown, currentTab, state, guistate),
},
]
@ -113,11 +115,12 @@ export default class FullWelcomePaneWithTabs extends ScrollableFullScreen {
filteredLayers: UIEventSource<FilteredLayer[]>
} & UserRelatedState,
currentTab: UIEventSource<number>,
isShown: UIEventSource<boolean>
isShown: UIEventSource<boolean>,
guistate?: { userInfoIsOpened: UIEventSource<boolean> }
) {
const tabs = FullWelcomePaneWithTabs.ConstructBaseTabs(state, isShown, currentTab)
const tabs = FullWelcomePaneWithTabs.ConstructBaseTabs(state, isShown, currentTab, guistate)
const tabsWithAboutMc = [
...FullWelcomePaneWithTabs.ConstructBaseTabs(state, isShown, currentTab),
...FullWelcomePaneWithTabs.ConstructBaseTabs(state, isShown, currentTab, guistate),
]
tabsWithAboutMc.push({

View file

@ -9,6 +9,7 @@ import Svg from "../../Svg"
import LayoutConfig from "../../Models/ThemeConfig/LayoutConfig"
import { OsmConnection } from "../../Logic/Osm/OsmConnection"
import FullWelcomePaneWithTabs from "./FullWelcomePaneWithTabs"
import LoggedInUserIndicator from "../LoggedInUserIndicator"
export default class ThemeIntroductionPanel extends Combine {
constructor(
@ -20,7 +21,8 @@ export default class ThemeIntroductionPanel extends Combine {
featureSwitchUserbadge: UIEventSource<boolean>
layoutToUse: LayoutConfig
osmConnection: OsmConnection
}
},
guistate?: { userInfoIsOpened: UIEventSource<boolean> }
) {
const t = Translations.t.general
const layout = state.layoutToUse
@ -36,9 +38,18 @@ export default class ThemeIntroductionPanel extends Combine {
})
.SetClass("only-on-mobile")
const loggedInUserInfo = new LoggedInUserIndicator(state.osmConnection, {
firstLine: Translations.t.general.welcomeBack.Clone(),
})
if (guistate?.userInfoIsOpened) {
loggedInUserInfo.onClick(() => {
guistate.userInfoIsOpened.setData(true)
})
}
const loginStatus = new Toggle(
new LoginToggle(
undefined,
loggedInUserInfo,
new Combine([
Translations.t.general.loginWithOpenStreetMap.SetClass("text-xl font-bold"),
Translations.t.general.loginOnlyNeededToEdit.Clone().SetClass("font-bold"),
@ -60,7 +71,7 @@ export default class ThemeIntroductionPanel extends Combine {
]).SetClass("flex flex-col mt-2"),
toTheMap,
loginStatus.SetClass("block"),
loginStatus.SetClass("block mt-6 pt-2 md:border-t-2 border-dotted border-gray-400"),
layout.descriptionTail?.Clone().SetClass("block mt-4"),
languagePicker?.SetClass("block mt-4"),

View file

@ -140,12 +140,17 @@ class UserInformationMainPanel extends Combine {
}
export default class UserInformationPanel extends ScrollableFullScreen {
constructor(state: {
constructor(
state: {
layoutToUse: LayoutConfig
osmConnection: OsmConnection
locationControl: UIEventSource<Loc>
}) {
const isOpened = new UIEventSource<boolean>(false)
},
options?: {
isOpened?: UIEventSource<boolean>
}
) {
const isOpened = options?.isOpened ?? new UIEventSource<boolean>(false)
super(
() => {
return new VariableUiElement(

View file

@ -33,7 +33,6 @@ import GeoLocationHandler from "../Logic/Actors/GeoLocationHandler"
import { GeoLocationState } from "../Logic/State/GeoLocationState"
import Hotkeys from "./Base/Hotkeys"
import AvailableBaseLayers from "../Logic/Actors/AvailableBaseLayers"
import { Translation } from "./i18n/Translation"
/**
* The default MapComplete GUI initializer
@ -205,7 +204,9 @@ export default class DefaultGUI {
const self = this
new Combine([
Toggle.If(state.featureSwitchUserbadge, () => {
const userInfo = new UserInformationPanel(state)
const userInfo = new UserInformationPanel(state, {
isOpened: guiState.userInfoIsOpened,
})
const mapControl = new MapControlButton(
new VariableUiElement(
@ -219,7 +220,7 @@ export default class DefaultGUI {
{
dontStyle: true,
}
).onClick(() => userInfo.Activate())
).onClick(() => guiState.userInfoIsOpened.setData(true))
return new LoginToggle(
mapControl,
@ -292,7 +293,12 @@ export default class DefaultGUI {
private InitWelcomeMessage(): BaseUIElement {
const isOpened = this.guiState.welcomeMessageIsOpened
new FullWelcomePaneWithTabs(isOpened, this.guiState.welcomeMessageOpenedTab, this.state)
new FullWelcomePaneWithTabs(
isOpened,
this.guiState.welcomeMessageOpenedTab,
this.state,
this.guiState
)
// ?-Button on Desktop, opens panel with close-X.
const help = new MapControlButton(Svg.help_svg())

View file

@ -9,6 +9,7 @@ export class DefaultGuiState {
public readonly filterViewIsOpened: UIEventSource<boolean>
public readonly copyrightViewIsOpened: UIEventSource<boolean>
public readonly currentViewControlIsOpened: UIEventSource<boolean>
public readonly userInfoIsOpened: UIEventSource<boolean>
public readonly welcomeMessageOpenedTab: UIEventSource<number>
public readonly allFullScreenStates: UIEventSource<boolean>[] = []
@ -43,8 +44,14 @@ export class DefaultGuiState {
this.currentViewControlIsOpened = QueryParameters.GetBooleanQueryParameter(
"currentview-toggle",
false,
"Whether or not the current view box is shown"
"Whether or not the current view box is shown (metalayer showing current view, allows to do calculate stats for all in view)"
)
this.userInfoIsOpened = QueryParameters.GetBooleanQueryParameter(
"userinfo-toggle",
false,
"Whether or not the user info is shown"
)
const states = {
download: this.downloadControlIsOpened,
filters: this.filterViewIsOpened,
@ -66,7 +73,8 @@ export class DefaultGuiState {
this.filterViewIsOpened,
this.copyrightViewIsOpened,
this.welcomeMessageIsOpened,
this.currentViewControlIsOpened
this.currentViewControlIsOpened,
this.userInfoIsOpened
)
for (let i = 0; i < this.allFullScreenStates.length; i++) {

View file

@ -0,0 +1,42 @@
import { VariableUiElement } from "./Base/VariableUIElement"
import { OsmConnection } from "../Logic/Osm/OsmConnection"
import Svg from "../Svg"
import Img from "./Base/Img"
import Combine from "./Base/Combine"
import { FixedUiElement } from "./Base/FixedUiElement"
import BaseUIElement from "./BaseUIElement"
export default class LoggedInUserIndicator extends VariableUiElement {
constructor(
osmConnection: OsmConnection,
options?: {
size?: "small" | "medium" | "large"
firstLine?: BaseUIElement
}
) {
options = options ?? {}
let size = "w-8 h-8 mr-2"
if (options.size == "medium") {
size = "w-16 h-16 mr-4"
} else if (options.size == "large") {
size = "w-32 h-32 mr-6"
}
super(
osmConnection.userDetails.mapD((ud) => {
let img = Svg.person_svg().SetClass(
"rounded-full border border-black overflow-hidden"
)
if (ud.img) {
img = new Img(ud.img)
}
let contents: BaseUIElement = new FixedUiElement(ud.name).SetClass("font-bold")
if (options?.firstLine) {
contents = new Combine([options.firstLine, contents]).SetClass("flex flex-col")
}
return new Combine([img.SetClass("rounded-full " + size), contents]).SetClass(
"flex items-center"
)
})
)
}
}

View file

@ -176,6 +176,13 @@ export default class DeleteWizard extends Toggle {
undefined,
isShown
)
const self = this
confirm.addCallbackAndRunD((dialogIsOpened) => {
if (dialogIsOpened) {
self.ScrollIntoView()
}
})
}
private static constructConfirmButton(

View file

@ -284,5 +284,13 @@ export default class MoveWizard extends Toggle {
]).SetClass("flex m-2 p-2 rounded-lg bg-gray-200"),
moveDisallowedReason.map((r) => r === undefined)
)
const self = this
currentStep.addCallback((state) => {
if (state === "start") {
return
}
self.ScrollIntoView()
})
}
}

View file

@ -24,6 +24,7 @@ import BaseLayer from "../../Models/BaseLayer"
import FilteredLayer from "../../Models/FilteredLayer"
import BaseUIElement from "../BaseUIElement"
import { VariableUiElement } from "../Base/VariableUIElement"
import ScrollableFullScreen from "../Base/ScrollableFullScreen"
export default class SplitRoadWizard extends Combine {
// @ts-ignore
@ -54,6 +55,7 @@ export default class SplitRoadWizard extends Combine {
changes: Changes
layoutToUse: LayoutConfig
allElements: ElementStorage
selectedElement: UIEventSource<any>
}
) {
const t = Translations.t.split
@ -79,9 +81,6 @@ export default class SplitRoadWizard extends Combine {
hasBeenSplit
)
)
splitButton.onClick(() => {
splitClicked.setData(true)
})
// Only show the splitButton if logged in, else show login prompt
const loginBtn = t.loginToSplit
@ -110,6 +109,9 @@ export default class SplitRoadWizard extends Combine {
// We throw away the old map and splitpoints, and create a new map from scratch
splitPoints.setData([])
leafletMap.setData(SplitRoadWizard.setupMapComponent(id, splitPoints, state))
// Close the popup. The contributor has to select a segment again to make sure they continue editing the correct segment; see #1219
ScrollableFullScreen.collapse()
})
saveButton.SetClass("btn btn-primary mr-3")
@ -147,6 +149,11 @@ export default class SplitRoadWizard extends Combine {
new Toggle(mapView, splitToggle, splitClicked),
])
this.dialogIsOpened = splitClicked
const self = this
splitButton.onClick(() => {
splitClicked.setData(true)
self.ScrollIntoView()
})
}
private static setupMapComponent(

View file

@ -4,8 +4,10 @@ import { ShowDataLayerOptions } from "./ShowDataLayerOptions"
import { ElementStorage } from "../../Logic/ElementStorage"
import RenderingMultiPlexerFeatureSource from "../../Logic/FeatureSource/Sources/RenderingMultiPlexerFeatureSource"
import ScrollableFullScreen from "../Base/ScrollableFullScreen"
import { LeafletMouseEvent } from "leaflet"
import { LeafletMouseEvent, PathOptions } from "leaflet"
import Hash from "../../Logic/Web/Hash"
import { BBox } from "../../Logic/BBox"
import { Utils } from "../../Utils"
/*
// import 'leaflet-polylineoffset';
We don't actually import it here. It is imported in the 'MinimapImplementation'-class, which'll result in a patched 'L' object.
@ -47,6 +49,7 @@ export default class ShowDataLayerImplementation {
string,
{ feature: any; activateFunc: (event: LeafletMouseEvent) => void }
>()
private readonly showDataLayerid: number
private readonly createPopup: (
tags: UIEventSource<any>,
@ -81,7 +84,7 @@ export default class ShowDataLayerImplementation {
}
const self = this
options.leafletMap.addCallback((_) => {
options.leafletMap.addCallback(() => {
return self.update(options)
})
@ -112,6 +115,10 @@ export default class ShowDataLayerImplementation {
})
this._selectedElement?.addCallbackAndRunD((selected) => {
if (selected === undefined) {
ScrollableFullScreen.collapse()
return
}
self.openPopupOfSelectedElement(selected)
})
@ -171,17 +178,8 @@ export default class ShowDataLayerImplementation {
}
const self = this
const data = {
type: "FeatureCollection",
features: [],
}
// @ts-ignore
this.geoLayer = L.geoJSON(data, {
style: (feature) => self.createStyleFor(feature),
pointToLayer: (feature, latLng) => self.pointToLayer(feature, latLng),
onEachFeature: (feature, leafletLayer) =>
self.postProcessFeature(feature, leafletLayer),
})
this.geoLayer = new L.LayerGroup()
const selfLayer = this.geoLayer
const allFeats = this._features.features.data
@ -189,6 +187,31 @@ export default class ShowDataLayerImplementation {
if (feat === undefined) {
continue
}
// Why not one geojson layer with _all_ features, and attaching a right-click onto every feature individually?
// Because that somehow doesn't work :(
const feature = feat
const geojsonLayer = L.geoJSON(feature, {
style: (feature) => <PathOptions>self.createStyleFor(feature),
pointToLayer: (feature, latLng) => self.pointToLayer(feature, latLng),
onEachFeature: (feature, leafletLayer) =>
self.postProcessFeature(feature, leafletLayer),
})
if (feature.geometry.type === "Point") {
geojsonLayer.on({
contextmenu: (e) => {
const o = self.leafletLayersPerId.get(feature?.properties?.id)
o?.activateFunc(<LeafletMouseEvent>e)
Utils.preventDefaultOnMouseEvent(e.originalEvent)
},
dblclick: (e) => {
const o = self.leafletLayersPerId.get(feature?.properties?.id)
o?.activateFunc(<LeafletMouseEvent>e)
Utils.preventDefaultOnMouseEvent(e.originalEvent)
},
})
}
this.geoLayer.addLayer(geojsonLayer)
try {
if (feat.geometry.type === "LineString") {
const coords = L.GeoJSON.coordsToLatLngs(feat.geometry.coordinates)
@ -229,7 +252,7 @@ export default class ShowDataLayerImplementation {
return self.geoLayer !== selfLayer
})
} else {
this.geoLayer.addData(feat)
geojsonLayer.addData(feat)
}
} catch (e) {
console.error(
@ -242,14 +265,14 @@ export default class ShowDataLayerImplementation {
}
}
if (options.zoomToFeatures ?? false) {
if (this.geoLayer.getLayers().length > 0) {
try {
const bounds = this.geoLayer.getBounds()
mp.fitBounds(bounds, { animate: false })
} catch (e) {
console.debug("Invalid bounds", e)
if ((options.zoomToFeatures ?? false) && allFeats.length > 0) {
let bound = undefined
for (const feat of allFeats) {
const fbound = BBox.get(feat)
bound = bound?.unionWith(fbound) ?? fbound
}
if (bound !== undefined) {
mp.fitBounds(bound?.toLeaflet(), { animate: false })
}
}
@ -312,29 +335,7 @@ export default class ShowDataLayerImplementation {
icon: L.divIcon(style),
})
}
/**
* Post processing - basically adding the popup
* @param feature
* @param leafletLayer
* @private
*/
private postProcessFeature(feature, leafletLayer: L.Evented) {
const layer: LayerConfig = this._layerToShow
if (layer.title === undefined || !this._enablePopups) {
// No popup action defined -> Don't do anything
// or probably a map in the popup - no popups needed!
return
}
const key = feature.properties.id
if (this.leafletLayersPerId.has(key)) {
const activate = this.leafletLayersPerId.get(key)
leafletLayer.addEventListener("click", activate.activateFunc)
if (Hash.hash.data === key) {
activate.activateFunc(null)
}
return
}
private createActivateFunction(feature, key: string, layer: LayerConfig): (event) => void {
let infobox: ScrollableFullScreen = undefined
const self = this
@ -354,17 +355,36 @@ export default class ShowDataLayerImplementation {
self._selectedElement.setData(
self.allElements.ContainingFeatures.get(feature.id) ?? feature
)
event?.originalEvent?.preventDefault()
event?.originalEvent?.stopPropagation()
event?.originalEvent?.stopImmediatePropagation()
if (event?.originalEvent) {
// This is a total workaround, as 'preventDefault' and everything above seems to be not working
event.originalEvent["dismissed"] = true
}
return activate
}
/**
* Post processing - basically adding the popup
* @param feature
* @param leafletLayer
* @private
*/
private postProcessFeature(feature, leafletLayer: L.Evented) {
const layer: LayerConfig = this._layerToShow
if (layer.title === undefined || !this._enablePopups) {
// No popup action defined -> Don't do anything
// or probably a map in the popup - no popups needed!
return
}
const key = feature.properties.id
let activate: (event) => void
if (this.leafletLayersPerId.has(key)) {
activate = this.leafletLayersPerId.get(key).activateFunc
} else {
activate = this.createActivateFunction(feature, key, layer)
}
leafletLayer.addEventListener("click", activate)
// We also have to open on rightclick, doubleclick, ... as users sometimes do this. See #1219
leafletLayer.on({
dblclick: activate,
contextmenu: activate,
click: activate,
})
// Add the feature to the index to open the popup when needed
this.leafletLayersPerId.set(key, {
feature: feature,

View file

@ -900,7 +900,7 @@ In the case that MapComplete is pointed to the testing grounds, the edit will be
url: string,
maxCacheTimeMs: number,
headers?: any
): Promise<any | { error: string; url: string; statuscode?: number }> {
): Promise<{ content: any } | { error: string; url: string; statuscode?: number }> {
const cached = Utils._download_cache.get(url)
if (cached !== undefined) {
if (new Date().getTime() - cached.timestamp <= maxCacheTimeMs) {
@ -1074,6 +1074,16 @@ In the case that MapComplete is pointed to the testing grounds, the edit will be
)
}
public static preventDefaultOnMouseEvent(event: any) {
event?.originalEvent?.preventDefault()
event?.originalEvent?.stopPropagation()
event?.originalEvent?.stopImmediatePropagation()
if (event?.originalEvent) {
// This is a total workaround, as 'preventDefault' and everything above seems to be not working
event.originalEvent["dismissed"] = true
}
}
public static OsmChaLinkFor(daysInThePast, theme = undefined): string {
const now = new Date()
const lastWeek = new Date(now.getTime() - daysInThePast * 24 * 60 * 60 * 1000)

View file

@ -624,6 +624,10 @@ video {
position: relative;
}
.\!relative {
position: relative !important;
}
.sticky {
position: -webkit-sticky;
position: sticky;
@ -807,18 +811,22 @@ video {
margin-top: 0.25rem;
}
.mt-4 {
margin-top: 1rem;
}
.ml-3 {
margin-left: 0.75rem;
}
.mr-2 {
margin-right: 0.5rem;
}
.mr-4 {
margin-right: 1rem;
}
.mr-6 {
margin-right: 1.5rem;
}
.mt-4 {
margin-top: 1rem;
}
.ml-4 {
margin-left: 1rem;
}
@ -827,10 +835,6 @@ video {
margin-bottom: 6rem;
}
.mr-4 {
margin-right: 1rem;
}
.mb-2 {
margin-bottom: 0.5rem;
}
@ -839,6 +843,10 @@ video {
margin-left: 0.5rem;
}
.ml-3 {
margin-left: 0.75rem;
}
.ml-12 {
margin-left: 3rem;
}
@ -960,6 +968,18 @@ video {
height: 16rem;
}
.h-8 {
height: 2rem;
}
.h-16 {
height: 4rem;
}
.h-32 {
height: 8rem;
}
.h-10 {
height: 2.5rem;
}
@ -988,22 +1008,10 @@ video {
height: 1.5rem;
}
.h-8 {
height: 2rem;
}
.h-32 {
height: 8rem;
}
.h-96 {
height: 24rem;
}
.h-16 {
height: 4rem;
}
.h-0 {
height: 0px;
}
@ -1048,6 +1056,18 @@ video {
width: 1.5rem;
}
.w-8 {
width: 2rem;
}
.w-16 {
width: 4rem;
}
.w-32 {
width: 8rem;
}
.w-10 {
width: 2.5rem;
}
@ -1072,10 +1092,6 @@ video {
width: 2.75rem;
}
.w-8 {
width: 2rem;
}
.w-min {
width: -webkit-min-content;
width: min-content;
@ -1090,14 +1106,6 @@ video {
width: 24rem;
}
.w-32 {
width: 8rem;
}
.w-16 {
width: 4rem;
}
.w-auto {
width: auto;
}
@ -1374,6 +1382,10 @@ video {
border-bottom-width: 1px;
}
.border-dotted {
border-style: dotted;
}
.border-black {
--tw-border-opacity: 1;
border-color: rgb(0 0 0 / var(--tw-border-opacity));
@ -1864,6 +1876,11 @@ body {
box-sizing: initial !important;
}
.leaflet-marker-icon img {
-webkit-touch-callout: none;
/* prevent callout to copy image, etc when tap to hold */
}
.leaflet-control-attribution {
display: block ruby;
}
@ -2738,6 +2755,10 @@ input {
border-radius: 0.75rem;
}
.md\:border-t-2 {
border-top-width: 2px;
}
.md\:p-1 {
padding: 0.25rem;
}

View file

@ -113,6 +113,10 @@ body {
box-sizing: initial !important;
}
.leaflet-marker-icon img {
-webkit-touch-callout: none; /* prevent callout to copy image, etc when tap to hold */
}
.leaflet-control-attribution {
display: block ruby;
}

View file

@ -327,7 +327,7 @@
"tuesday": "Tuesday",
"wednesday": "Wednesday"
},
"welcomeBack": "You are logged in, welcome back!",
"welcomeBack": "Welcome back!",
"welcomeExplanation": {
"addNew": "Tap the map to add a new POI.",
"browseMoreMaps": "Discover more maps",