Rearrange controls in narrow mode

pull/269/head
Candid Dauth 2024-04-26 03:44:21 +02:00
rodzic e585e5917e
commit e9f64750f5
12 zmienionych plików z 265 dodań i 90 usunięć

Wyświetl plik

@ -469,7 +469,8 @@
},
"leaflet-map": {
"open-full-size": "{{appName}} als ganze Seite öffnen",
"loading": "Wird geladen…"
"loading": "Wird geladen…",
"attribution-notice": "Diese App basiert auf Kartendaten von [OpenStreetMap](https://www.openstreetmap.org/copyright/de). Mehr Details im Dialog „[Über {{appName}}](#about-dialog)“ im Hilfe-Menü."
},
"leaflet-map-components": {
"pois-too-many-results": "Nicht alle POIs konnten geladen werden, weil zu viele gefunden wurden. Zoom Sie weiter hinein, um alle POIs anzuzeigen.",

Wyświetl plik

@ -471,7 +471,8 @@
},
"leaflet-map": {
"open-full-size": "Open {{appName}} in full size",
"loading": "Loading…"
"loading": "Loading…",
"attribution-notice": "This app is based on map data from [OpenStreetMap](https://www.openstreetmap.org/copyright). Find out more in the “[About {{appName}}](#about-dialog)” dialog in the Help menu."
},
"leaflet-map-components": {
"pois-too-many-results": "Not all POIs are shown because there are too many results. Zoom in to show all results.",

Wyświetl plik

@ -1,6 +1,6 @@
<script lang="ts">
import { type InjectionKey, type Ref, inject, onScopeDispose, provide, shallowReactive, toRef, watch, reactive, readonly, shallowReadonly } from "vue";
import { useMaxBreakpoint } from "../../utils/bootstrap";
import { useIsNarrow } from "../../utils/bootstrap";
import type { FacilMapComponents, FacilMapContext, FacilMapSettings } from "./facil-map-context";
const contextInject = Symbol("contextInject") as InjectionKey<FacilMapContext>;
@ -45,7 +45,7 @@
appName: "FacilMap"
});
const isNarrow = useMaxBreakpoint("sm");
const isNarrow = useIsNarrow();
const components = shallowReactive<FacilMapComponents>({});

Wyświetl plik

@ -13,6 +13,7 @@ export type MapContextEvents = {
};
export interface MapComponents {
zoomControl: L.Control.Zoom;
attribution: AttributionControl;
bboxHandler: BboxHandler;
container: HTMLElement;

Wyświetl plik

@ -1,5 +1,5 @@
import { type Ref, ref, watch, markRaw, reactive, watchEffect, shallowRef, shallowReadonly, type Raw, nextTick } from "vue";
import { type Control, latLng, latLngBounds, type Map, map as leafletMap, DomUtil, control } from "leaflet";
import { type Ref, ref, watch, markRaw, reactive, watchEffect, shallowRef, shallowReadonly, type Raw, nextTick, effectScope, onScopeDispose } from "vue";
import { Control, latLng, latLngBounds, type Map, map as leafletMap, DomUtil, control } from "leaflet";
import "leaflet/dist/leaflet.css";
import { BboxHandler, getIconHtml, getVisibleLayers, HashHandler, LinesLayer, MarkersLayer, SearchResultsLayer, OverpassLayer, OverpassLoadStatus, displayView, getInitialView, coreIconList } from "facilmap-leaflet";
import "leaflet.locatecontrol";
@ -18,17 +18,20 @@ import { requireClientContext } from "../facil-map-context-provider/facil-map-co
import { type Optional } from "facilmap-utils";
import { getI18n, i18nResourceChangeCounter } from "../../utils/i18n";
import { AttributionControl } from "./attribution";
import { fixOnCleanup } from "../../utils/vue";
import { isMaxBreakpoint, isNarrowBreakpoint } from "../../utils/bootstrap";
type MapContextWithoutComponents = Optional<WritableMapContext, 'components'>;
type OnCleanup = (cleanupFn: () => void) => void;
function useMap(element: Ref<HTMLElement>, mapContext: MapContextWithoutComponents): Ref<Raw<Map>> {
const mapRef = shallowRef(undefined as any as Map);
const interaction = ref(0);
watchEffect((onCleanup) => {
const map = mapRef.value = markRaw(leafletMap(element.value, { boxZoom: false, attributionControl: false }));
const map = mapRef.value = markRaw(leafletMap(element.value, {
boxZoom: false,
attributionControl: false,
zoomControl: false
}));
map._controlCorners.bottomcenter = DomUtil.create("div", "leaflet-bottom fm-leaflet-center", map._controlContainer);
@ -80,7 +83,7 @@ function useMap(element: Ref<HTMLElement>, mapContext: MapContextWithoutComponen
function useMapComponent<T>(
map: Ref<Map>,
construct: () => T,
activate: (component: T, onCleanup: OnCleanup) => void
activate: (component: T, map: Map) => void
): Ref<T> {
const componentRef = shallowRef(undefined as any as T);
watchEffect(() => {
@ -90,26 +93,56 @@ function useMapComponent<T>(
watch([
componentRef,
map
], ([component], prev, onCleanup_) => {
const onCleanup = fixOnCleanup(onCleanup_);
activate(component as T, onCleanup);
], ([component, map], prev, onCleanup) => {
const scope = effectScope();
scope.run(() => {
activate(component as any, map);
});
onCleanup(() => {
scope.stop();
});
}, { immediate: true });
return componentRef;
}
function useZoomControl(map: Ref<Map>): Ref<Raw<Control.Zoom>> {
return useMapComponent(
map,
() => markRaw(control.zoom()),
(zoomControl, map) => {
watch(() => isNarrowBreakpoint(), (isNarrow) => {
zoomControl.setPosition(isNarrow ? "bottomright" : "topleft");
}, { immediate: true });
map.addControl(zoomControl);
onScopeDispose(() => {
zoomControl.remove();
});
}
);
}
function useAttribution(map: Ref<Map>): Ref<Raw<AttributionControl>> {
return useMapComponent(
map,
() => markRaw(new AttributionControl()),
(attribution, onCleanup) => {
map.value.addControl(attribution);
(attribution, map) => {
watch(() => isNarrowBreakpoint(), (isNarrow) => {
if (isNarrow) {
attribution.remove();
} else {
map.addControl(attribution);
}
//attribution.setPosition(isNarrow ? "topleft" : "bottomright");
}, { immediate: true });
const i18nWatcher = watch(i18nResourceChangeCounter, () => {
watch(i18nResourceChangeCounter, () => {
attribution.update();
});
onCleanup(() => {
i18nWatcher();
onScopeDispose(() => {
attribution.remove();
});
}
);
@ -119,9 +152,9 @@ function useBboxHandler(map: Ref<Map>, client: Ref<ClientContext>): Ref<Raw<Bbox
return useMapComponent(
map,
() => markRaw(new BboxHandler(map.value, client.value)),
(bboxHandler, onCleanup) => {
(bboxHandler) => {
bboxHandler.enable();
onCleanup(() => {
onScopeDispose(() => {
bboxHandler.disable();
});
}
@ -132,9 +165,16 @@ function useGraphicScale(map: Ref<Map>): Ref<Raw<any>> {
return useMapComponent(
map,
() => markRaw(control.graphicScale({ fill: "hollow", position: "bottomcenter" })),
(graphicScale, onCleanup) => {
graphicScale.addTo(map.value);
onCleanup(() => {
(graphicScale, map) => {
watch(() => isNarrowBreakpoint(), (isNarrow) => {
if (isNarrow) {
graphicScale.remove();
} else {
graphicScale.addTo(map);
}
}, { immediate: true });
onScopeDispose(() => {
graphicScale.remove();
});
}
@ -145,9 +185,9 @@ function useLinesLayer(map: Ref<Map>, client: Ref<ClientContext>): Ref<Raw<Lines
return useMapComponent(
map,
() => markRaw(new LinesLayer(client.value)),
(linesLayer, onCleanup) => {
linesLayer.addTo(map.value);
onCleanup(() => {
(linesLayer, map) => {
linesLayer.addTo(map);
onScopeDispose(() => {
linesLayer.remove();
});
}
@ -159,10 +199,14 @@ function useLocateControl(map: Ref<Map>, context: FacilMapContext): Ref<Raw<Cont
map,
() => {
if (context.settings.locate) {
if (!coreIconList.includes("screenshot")) {
console.warn(`Icon "screenshot" is not in core icons.`);
}
let screenshotIconHtmlP = getIconHtml("currentColor", "1.5em", "screenshot");
return markRaw(control.locate({
flyTo: true,
icon: "a",
iconLoading: "a",
markerStyle: { pane: "fm-raised-marker", zIndexOffset: 10000 },
locateOptions: {
enableHighAccuracy: true
@ -171,25 +215,29 @@ function useLocateControl(map: Ref<Map>, context: FacilMapContext): Ref<Raw<Cont
inView: "stop",
outOfView: "setView",
inViewNotFollowing: "outOfView"
},
createButtonCallback: (container, options) => {
const { link, icon } = (Control.Locate.prototype.options as Control.LocateOptions).createButtonCallback!(container, options) as any as { link: HTMLElement; icon: HTMLElement };
icon.remove();
const newIcon = document.createElement("span");
link.appendChild(newIcon);
screenshotIconHtmlP.then((iconHtml) => {
newIcon.innerHTML = iconHtml;
}).catch(console.error);
return { link, icon: newIcon };
}
}));
}
},
(locateControl, onCleanup) => {
(locateControl, map) => {
if (locateControl) {
locateControl.addTo(map.value);
watch(() => isNarrowBreakpoint(), (isNarrow) => {
locateControl.setPosition(isNarrow ? "bottomright" : "topleft");
}, { immediate: true });
if (!coreIconList.includes("screenshot")) {
console.warn(`Icon "screenshot" is not in core icons.`);
}
locateControl.addTo(map);
getIconHtml("currentColor", "1.5em", "screenshot").then((html) => {
locateControl._container.querySelector("a")?.insertAdjacentHTML("beforeend", html);
}).catch((err) => {
console.error("Error loading locate control icon", err);
});
onCleanup(() => {
onScopeDispose(() => {
locateControl.remove();
});
}
@ -201,9 +249,9 @@ function useMarkersLayer(map: Ref<Map>, client: Ref<ClientContext>): Ref<Raw<Mar
return useMapComponent(
map,
() => markRaw(new MarkersLayer(client.value)),
(markersLayer, onCleanup) => {
markersLayer.addTo(map.value);
onCleanup(() => {
(markersLayer, map) => {
markersLayer.addTo(map);
onScopeDispose(() => {
markersLayer.remove();
});
}
@ -214,9 +262,16 @@ function useMousePosition(map: Ref<Map>): Ref<Raw<Control.MousePosition>> {
return useMapComponent(
map,
() => markRaw(control.mousePosition({ emptyString: "0, 0", separator: ", ", position: "bottomright" })),
(mousePosition, onCleanup) => {
mousePosition.addTo(map.value);
onCleanup(() => {
(mousePosition, map) => {
watch(() => isNarrowBreakpoint(), (isNarrow) => {
if (isNarrow) {
mousePosition.remove();
} else {
mousePosition.addTo(map);
}
}, { immediate: true });
onScopeDispose(() => {
mousePosition.remove();
});
}
@ -252,9 +307,9 @@ function useOverpassLayer(map: Ref<Map>, mapContext: MapContextWithoutComponents
.on("clear", () => {
mapContext.overpassMessage = undefined;
}),
(overpassLayer, onCleanup) => {
overpassLayer.addTo(map.value)
onCleanup(() => {
(overpassLayer, map) => {
overpassLayer.addTo(map)
onScopeDispose(() => {
overpassLayer.remove();
});
}
@ -265,9 +320,9 @@ function useSearchResultsLayer(map: Ref<Map>): Ref<Raw<SearchResultsLayer>> {
return useMapComponent(
map,
() => markRaw(new SearchResultsLayer(undefined, { pathOptions: { weight: 7 } })),
(searchResultsLayer, onCleanup) => {
searchResultsLayer.addTo(map.value);
onCleanup(() => {
(searchResultsLayer, map) => {
searchResultsLayer.addTo(map);
onScopeDispose(() => {
searchResultsLayer.remove();
});
}
@ -301,9 +356,9 @@ function useSelectionHandler(map: Ref<Map>, context: FacilMapContext, mapContext
return selectionHandler;
},
(selectionHandler, onCleanup) => {
(selectionHandler) => {
selectionHandler.enable();
onCleanup(() => {
onScopeDispose(() => {
selectionHandler.disable();
});
}
@ -339,8 +394,8 @@ function useHashHandler(map: Ref<Map>, client: Ref<ClientContext>, context: Faci
}
});
},
(hashHandler, onCleanup) => {
onCleanup(() => {
(hashHandler) => {
onScopeDispose(() => {
hashHandler.disable();
});
}
@ -351,6 +406,7 @@ function useMapComponents(context: FacilMapContext, mapContext: MapContextWithou
const client = requireClientContext(context);
const map = useMap(mapRef, mapContext);
const attribution = useAttribution(map);
const zoomControl = useZoomControl(map);
const bboxHandler = useBboxHandler(map, client);
const graphicScale = useGraphicScale(map);
const linesLayer = useLinesLayer(map, client);
@ -364,6 +420,7 @@ function useMapComponents(context: FacilMapContext, mapContext: MapContextWithou
const components: MapComponents = reactive({
map,
zoomControl,
attribution,
bboxHandler,
graphicScale,

Wyświetl plik

@ -4,7 +4,9 @@
import vTooltip from "../../utils/tooltip";
import type { WritableMapContext } from "../facil-map-context-provider/map-context";
import { injectContextRequired, requireClientContext } from "../facil-map-context-provider/facil-map-context-provider.vue";
import { useI18n } from "../../utils/i18n";
import { useI18n, vReplaceLinks } from "../../utils/i18n";
import AboutDialog from "../about-dialog.vue";
import { markdownInline } from "facilmap-utils";
const context = injectContextRequired();
const client = requireClientContext(context);
@ -15,6 +17,8 @@
const loaded = ref(false);
const fatalError = ref<string>();
const showNarrowAttribution = ref(true);
const aboutDialogOpen = ref(false);
const selfUrl = computed(() => {
return `${location.origin}${location.pathname}${mapContext.value?.hash ? `#${mapContext.value.hash}` : ''}`;
@ -26,6 +30,9 @@
try {
mapContext.value = await useMapContext(context, mapRef as Ref<HTMLElement>, innerContainerRef as Ref<HTMLElement>);
loaded.value = true;
setTimeout(() => {
showNarrowAttribution.value = false;
}, 5000); // According to https://osmfoundation.org/wiki/Licence/Attribution_Guidelines#Interactive_maps we can fade out the attribution after 5 seconds
} catch (err: any) {
console.error(err);
fatalError.value = err.message;
@ -43,6 +50,19 @@
<div class="fm-leaflet-map-inner-container" ref="innerContainerRef">
<div class="fm-leaflet-map" ref="mapRef"></div>
<div
class="fm-leaflet-map-narrow-attribution"
:class="{ visible: showNarrowAttribution }"
v-html="markdownInline(i18n.t('leaflet-map.attribution-notice', { appName: context.appName }), true)"
v-replace-links="{
'#about-dialog': { onClick: () => { aboutDialogOpen = true; } }
}"
></div>
<AboutDialog
v-if="aboutDialogOpen"
@hidden="aboutDialogOpen = false"
></AboutDialog>
<div v-if="mapContext && mapContext.overpassMessage" class="alert alert-warning fm-overpass-message">
{{mapContext.overpassMessage}}
</div>
@ -118,6 +138,10 @@
pointer-events: none;
padding-right: 0;
// Make font size the same as attribution control
font-size: inherit;
line-height: 1.4;
&:after {
content: " |";
}
@ -162,8 +186,47 @@
}
&.isNarrow {
.leaflet-control-graphicscale,.leaflet-control-mouseposition {
display: none !important;
.leaflet-control-locate {
float: none;
position: absolute;
bottom: 0px;
right: 44px;
}
.leaflet-control-zoom {
border: none;
.leaflet-control-zoom-in {
margin-bottom: 10px;
}
.leaflet-control-zoom-in,.leaflet-control-zoom-out {
border: 2px solid rgba(0,0,0,0.2);
width: 34px;
height: 34px;
border-radius: 4px;
background-clip: padding-box;
}
}
}
.fm-leaflet-map-narrow-attribution {
position: absolute;
top: 0;
left: 0;
max-width: calc(100% - 54px);
opacity: 0;
transition: opacity 1s;
// Style like attribution control
background: rgba(255, 255, 255, 0.8);
padding: 0 5px;
color: #333;
line-height: 1.4;
font-size: 0.75rem;
&.visible {
opacity: 1;
}
}

Wyświetl plik

@ -1,7 +1,7 @@
<script setup lang="ts">
<script lang="ts">
import Sidebar from "../ui/sidebar.vue";
import Icon from "../ui/icon.vue";
import { ref } from "vue";
import { ref, toRef, useCssModule, watchEffect } from "vue";
import ToolboxAddDropdown from "./toolbox-add-dropdown.vue";
import ToolboxCollabMapsDropdown from "./toolbox-collab-maps-dropdown.vue";
import ToolboxHelpDropdown from "./toolbox-help-dropdown.vue";
@ -9,8 +9,20 @@
import ToolboxToolsDropdown from "./toolbox-tools-dropdown.vue";
import ToolboxViewsDropdown from "./toolbox-views-dropdown.vue";
import { injectContextRequired, requireClientContext } from "../facil-map-context-provider/facil-map-context-provider.vue";
import { isNarrowBreakpoint } from "../../utils/bootstrap";
import { fixOnCleanup } from "../../utils/vue";
import { Control, DomUtil, type Map } from "leaflet";
class CustomControl extends Control {
override onAdd(map: Map) {
return DomUtil.create("div", "leaflet-bar");
}
}
</script>
<script setup lang="ts">
const context = injectContextRequired();
const mapContext = toRef(() => context.components.map);
const client = requireClientContext(context);
const props = withDefaults(defineProps<{
@ -19,18 +31,39 @@
interactive: true
});
const styles = useCssModule();
const sidebarVisible = ref(false);
const menuButtonContainerRef = ref<HTMLElement>();
watchEffect((onCleanup_) => {
const onCleanup = fixOnCleanup(onCleanup_);
if (isNarrowBreakpoint() && mapContext.value) {
const customControl = new CustomControl({ position: "topright" });
customControl.addTo(mapContext.value.components.map);
customControl._container.classList.add(styles["toggle-container"]);
menuButtonContainerRef.value = customControl._container;
onCleanup(() => {
menuButtonContainerRef.value = undefined;
customControl.remove();
});
}
});
</script>
<template>
<div class="fm-toolbox">
<a
v-if="context.isNarrow"
v-show="!sidebarVisible"
href="javascript:"
class="fm-toolbox-toggle"
@click="sidebarVisible = true"
><Icon icon="menu-hamburger"></Icon></a>
<Teleport v-if="menuButtonContainerRef" :to="menuButtonContainerRef">
<a
v-show="!sidebarVisible"
href="javascript:"
class="fm-toolbox-toggle"
@click="sidebarVisible = true"
><Icon icon="menu-hamburger" size="1.5em"></Icon></a>
</Teleport>
<Sidebar :id="`fm${context.id}-toolbox-sidebar`" v-model:visible="sidebarVisible">
<ul class="navbar-nav">
@ -75,22 +108,6 @@
z-index: 1000;
}
.fm-toolbox-toggle {
color: #444;
border-radius: 4px;
background: #fff;
border: 2px solid rgba(0,0,0,0.2);
width: 34px;
height: 34px;
display: flex;
align-items: center;
justify-content: center;
&:hover {
background: #f4f4f4;
}
}
.fm-sidebar.isNarrow {
.navbar-nav {
max-width: 100%;
@ -124,4 +141,12 @@
display: none;
}
}
</style>
<style lang="scss" module>
.toggle-container > a {
display: inline-flex;
align-items: center;
justify-content: center;
}
</style>

Wyświetl plik

@ -1,6 +1,6 @@
<script setup lang="ts">
import { type SlotsType, computed, defineComponent, h, ref, shallowRef, useSlots, watch, watchEffect } from "vue";
import { getMaxSizeModifiers, type ButtonSize, type ButtonVariant, useMaxBreakpoint } from "../../utils/bootstrap";
import { getMaxSizeModifiers, type ButtonSize, type ButtonVariant, useIsNarrow } from "../../utils/bootstrap";
import Dropdown from "bootstrap/js/dist/dropdown";
import vLinkDisabled from "../../utils/link-disabled";
import type { TooltipPlacement } from "../../utils/tooltip";
@ -42,7 +42,7 @@
const buttonRef = ref<InstanceType<typeof AttributePreservingElement>>();
const dropdownRef = shallowRef<Dropdown>();
const isNarrow = useMaxBreakpoint("sm");
const isNarrow = useIsNarrow();
class CustomDropdown extends Dropdown {
_detectNavbar() {

Wyświetl plik

@ -3,7 +3,6 @@
import { onMounted, ref, watchEffect } from "vue";
import { useRefWithOverride } from "../../utils/vue";
import { injectContextRequired } from "../facil-map-context-provider/facil-map-context-provider.vue";
import { getUniqueId } from "../../utils/utils";
const context = injectContextRequired();

Wyświetl plik

@ -35,6 +35,10 @@ export function isMaxBreakpoint(breakpoint: Breakpoint): boolean {
return breakpoints.indexOf(reactiveBreakpoint.value) <= breakpoints.indexOf(breakpoint);
}
export function isNarrowBreakpoint(): boolean {
return isMaxBreakpoint("sm");
}
/**
* Returns a reactive boolean that is true if the current breakpoint is the specified one or smaller.
*/
@ -42,6 +46,10 @@ export function useMaxBreakpoint(breakpoint: Breakpoint): Ref<boolean> {
return computed(() => isMaxBreakpoint(breakpoint));
}
export function useIsNarrow(): Ref<boolean> {
return computed(() => isNarrowBreakpoint());
}
/**
* Returns a reactive boolean that is true if the current breakpoint is the specified one or larger.
*/

Wyświetl plik

@ -1,6 +1,6 @@
/// <reference types="vite/client" />
import { type i18n } from "i18next";
import { defineComponent, ref } from "vue";
import { defineComponent, ref, type Directive } from "vue";
import messagesEn from "../../i18n/en.json";
import messagesDe from "../../i18n/de.json";
import messagesNbNo from "../../i18n/nb-NO.json";
@ -98,6 +98,26 @@ export const T = defineComponent({
}
});
/**
* Replaces all descendent links of the element that match the given URLs with the given link configuration. This allows
* replacing links in translation texts using markdown with custom functionality (such as opening a dialog).
*/
export const vReplaceLinks: Directive<HTMLElement, Record<string, { onClick: (e: Event) => void }>> = (el, binding) => {
for (const link of el.querySelectorAll("a[href]")) {
const href = link.getAttribute("href");
if (binding.value[href!]) {
link.setAttribute("href", "javascript:");
link.removeAttribute("target");
link.addEventListener("click", (e) => {
e.preventDefault();
binding.value[href!].onClick(e);
});
}
}
};
export function isLanguageExplicit(): boolean {
const queryParams = decodeQueryString(location.search);
return !!queryParams[LANG_QUERY] || !!queryParams[LANG_COOKIE];

Wyświetl plik

@ -114,12 +114,12 @@ export function createDefaultLayers(): Layers & { fallbackLayer: string | undefi
noWrap: true
})),
Rlie: fixAttribution(tileLayer("https://tiles.wmflabs.org/hillshading/{z}/{x}/{y}.png", {
Rlie: tileLayer("https://tiles.wmflabs.org/hillshading/{z}/{x}/{y}.png", {
maxZoom: 16,
...fmName(() => getI18n().t("layers.rlie-name")),
zIndex: 300,
noWrap: true
})),
}),
grid: new AutoGraticule({
...fmName(() => getI18n().t("layers.grid-name")),