Allow picking coordinates from map.

This commit is contained in:
baldo 2022-08-26 18:01:06 +02:00
parent 56e247e031
commit b5eaf0b637
6 changed files with 205 additions and 15 deletions

View file

@ -1,21 +1,32 @@
<script setup lang="ts"> <script setup lang="ts">
import "leaflet/dist/leaflet.css"; import "leaflet/dist/leaflet.css";
import { onMounted, ref } from "vue"; import { onMounted, ref, watch } from "vue";
import { useConfigStore } from "@/stores/config"; import { useConfigStore } from "@/stores/config";
import type { Coordinates } from "@/types"; import type { Coordinates } from "@/types";
import * as L from "leaflet"; import * as L from "leaflet";
import { parseToFloat } from "@/utils/Numbers"; import { parseToFloat } from "@/utils/Numbers";
import type { LatLngTuple } from "leaflet";
import { forConstraint } from "@/shared/validation/validator";
import CONSTRAINTS from "@/shared/validation/constraints";
const wrapper = ref<HTMLElement>(); const wrapper = ref<HTMLElement>();
const configStore = useConfigStore(); const configStore = useConfigStore();
interface Props { interface Props {
coordinates?: Coordinates; coordinates?: Coordinates;
editable?: boolean;
} }
const props = defineProps<Props>(); const props = defineProps<Props>();
const emit = defineEmits<{
(e: "coordinatesSelected", value: Coordinates): void;
}>();
let map: L.Map | null = null;
let marker: L.Marker | null = null;
onMounted(renderMap); onMounted(renderMap);
watch(props, updateMarker);
function getLayers(): { function getLayers(): {
layers: { [name: string]: L.Layer }; layers: { [name: string]: L.Layer };
@ -41,6 +52,18 @@ function getLayers(): {
return { layers, defaultLayers }; return { layers, defaultLayers };
} }
function getCoordinates(): LatLngTuple | undefined {
const coordinates = props.coordinates;
if (!coordinates) {
return undefined;
}
if (!forConstraint(CONSTRAINTS.node.coords, false)(coordinates)) {
return undefined;
}
const [lat, lng] = props.coordinates.split(" ").map(parseToFloat);
return [lat, lng];
}
function createMap(defaultLayers: L.Layer[], layers: { [p: string]: L.Layer }) { function createMap(defaultLayers: L.Layer[], layers: { [p: string]: L.Layer }) {
const element = wrapper.value; const element = wrapper.value;
if (!element) { if (!element) {
@ -57,27 +80,64 @@ function createMap(defaultLayers: L.Layer[], layers: { [p: string]: L.Layer }) {
attributionControl: true, attributionControl: true,
layers: defaultLayers, layers: defaultLayers,
}; };
const map = L.map(element, options); map = L.map(element, options);
L.control.layers(layers).addTo(map); L.control.layers(layers).addTo(map);
return map; if (props.editable) {
map.on("click", onClick);
}
} }
function centerOnCoordinates(map: L.Map) { function updateMarker() {
const coordinates = getCoordinates();
if (!coordinates) {
return;
}
if (!map) {
console.error("Map is not initialized.");
return;
}
if (marker) {
marker.setLatLng(coordinates);
} else {
marker = L.marker(coordinates, {}).addTo(map);
}
if (!map.getBounds().contains(marker.getLatLng())) {
map.setView(marker.getLatLng(), map.getZoom());
}
}
function centerOnCoordinates() {
if (!map) {
console.error("Map is not initialized.");
return;
}
let { lat, lng, defaultZoom: zoom } = configStore.getConfig.coordsSelector; let { lat, lng, defaultZoom: zoom } = configStore.getConfig.coordsSelector;
if (props.coordinates) { const coordinates = getCoordinates();
[lat, lng] = props.coordinates.split(" ").map(parseToFloat);
if (coordinates) {
[lat, lng] = coordinates;
zoom = map.getMaxZoom(); zoom = map.getMaxZoom();
L.marker([lat, lng], {}).addTo(map);
} }
map.setView([lat, lng], zoom); map.setView([lat, lng], zoom);
} }
function renderMap() { function renderMap() {
const { layers, defaultLayers } = getLayers(); const { layers, defaultLayers } = getLayers();
const map = createMap(defaultLayers, layers); createMap(defaultLayers, layers);
centerOnCoordinates(map); centerOnCoordinates();
updateMarker();
}
function onClick(event: L.LeafletMouseEvent) {
emit(
"coordinatesSelected",
`${event.latlng.lat} ${event.latlng.lng}` as Coordinates
);
} }
</script> </script>

View file

@ -4,8 +4,9 @@ import { type Constraint, forConstraint } from "@/shared/validation/validator";
import ExpandableHelpBox from "@/components/ExpandableHelpBox.vue"; import ExpandableHelpBox from "@/components/ExpandableHelpBox.vue";
interface Props { interface Props {
name: string;
modelValue?: string; modelValue?: string;
label: string; label?: string;
type?: string; type?: string;
placeholder: string; placeholder: string;
constraint: Constraint; constraint: Constraint;
@ -19,7 +20,11 @@ const emit = defineEmits<{
}>(); }>();
const displayLabel = computed(() => const displayLabel = computed(() =>
props.constraint.optional ? props.label : `${props.label}*` props.label
? props.constraint.optional
? props.label
: `${props.label}*`
: undefined
); );
const input = ref<HTMLInputElement>(); const input = ref<HTMLInputElement>();
@ -75,17 +80,27 @@ onMounted(() => {
<template> <template>
<div class="validation-form-input"> <div class="validation-form-input">
<label> <label v-if="displayLabel">
{{ displayLabel }}: {{ displayLabel }}:
<ExpandableHelpBox v-if="props.help" :text="props.help" /> <ExpandableHelpBox v-if="props.help" :text="props.help" />
<input <input
ref="input" ref="input"
:name="props.name"
:value="props.modelValue" :value="props.modelValue"
@input="onInput" @input="onInput"
:type="props.type || 'text'" :type="props.type || 'text'"
:placeholder="props.placeholder" :placeholder="props.placeholder"
/> />
</label> </label>
<input
v-if="!displayLabel"
ref="input"
:name="props.name"
:value="props.modelValue"
@input="onInput"
:type="props.type || 'text'"
:placeholder="props.placeholder"
/>
<div class="validation-error" v-if="!valid"> <div class="validation-error" v-if="!valid">
{{ props.validationError }} {{ props.validationError }}
</div> </div>

View file

@ -0,0 +1,95 @@
<script setup lang="ts">
import ValidationFormInput from "@/components/form/ValidationFormInput.vue";
import type { Coordinates } from "@/types";
import { useConfigStore } from "@/stores/config";
import CONSTRAINTS from "@/shared/validation/constraints";
import NodeMap from "@/components/NodeMap.vue";
interface Props {
modelValue?: Coordinates;
}
const props = defineProps<Props>();
const emit = defineEmits<{
(e: "update:modelValue", value: Coordinates): void;
}>();
const configStore = useConfigStore();
function onUpdateModelValue(value: string) {
emit("update:modelValue", value as Coordinates);
}
</script>
<template>
<div class="node-coordinates-input">
<div class="input-panel">
<p class="help-block">
Wenn Du möchtest, dass Dein Knoten an der richtigen Stelle auf
der
<a :href="configStore.getConfig.map.mapUrl" target="_blank"
>Knotenkarte</a
>
angezeigt wird, kannst Du seine Koordinaten hier eintragen.
Klicke einfach in der auf dieser Seite angezeigten Karte an die
Stelle, wo Dein Knoten erscheinen soll. Durch erneutes Klicken
kannst Du die Position jederzeit anpassen.
</p>
<ValidationFormInput
name="coords"
:model-value="props.modelValue"
@update:modelValue="onUpdateModelValue"
:placeholder="`z. B. ${configStore.getConfig.coordsSelector.lat} ${configStore.getConfig.coordsSelector.lng}`"
:constraint="CONSTRAINTS.node.coords"
:validation-error="`Bitte gib die Koordinaten wie folgt an, Beispiel: ${configStore.getConfig.coordsSelector.lat} ${configStore.getConfig.coordsSelector.lng}`"
/>
</div>
<div class="node-map">
<NodeMap
@coordinatesSelected="onUpdateModelValue"
:coordinates="props.modelValue"
:editable="true"
/>
</div>
</div>
</template>
<style lang="scss">
@import "../../scss/variables";
@import "../../scss/mixins";
.node-coordinates-input {
display: flex;
align-items: flex-start;
margin: $node-coordinates-input-margin;
.help-block {
margin-top: 0;
}
.input-panel {
width: $node-coordinates-input-input-panel-width;
}
.node-map {
margin-left: $node-coordinates-input-horizontal-gap;
width: 100%;
}
}
@include max-page-breakpoint(small) {
.node-coordinates-input {
flex-direction: column;
.input-panel {
width: 100%;
}
.node-map {
margin: 0;
}
}
}
</style>

View file

@ -26,6 +26,7 @@ import ValidationFormInput from "@/components/form/ValidationFormInput.vue";
import { route, RouteName } from "@/router"; import { route, RouteName } from "@/router";
import RouteButton from "@/components/form/RouteButton.vue"; import RouteButton from "@/components/form/RouteButton.vue";
import { ApiError } from "@/utils/Api"; import { ApiError } from "@/utils/Api";
import NodeCoordinatesInput from "@/components/nodes/NodeCoordinatesInput.vue";
const configStore = useConfigStore(); const configStore = useConfigStore();
const nodeStore = useNodeStore(); const nodeStore = useNodeStore();
@ -143,6 +144,7 @@ async function onSubmit() {
<ValidationFormInput <ValidationFormInput
v-model="hostnameModel" v-model="hostnameModel"
name="hostname"
label="Knotenname" label="Knotenname"
placeholder="z. B. Lisas-Freifunk" placeholder="z. B. Lisas-Freifunk"
:constraint="CONSTRAINTS.node.hostname" :constraint="CONSTRAINTS.node.hostname"
@ -151,6 +153,7 @@ async function onSubmit() {
/> />
<ValidationFormInput <ValidationFormInput
v-model="fastdKeyModel" v-model="fastdKeyModel"
name="key"
label="VPN-Schlüssel (bitte nur weglassen, wenn Du weisst, was Du tust)" label="VPN-Schlüssel (bitte nur weglassen, wenn Du weisst, was Du tust)"
placeholder="Dein 64-stelliger VPN-Schlüssel" placeholder="Dein 64-stelliger VPN-Schlüssel"
:constraint="CONSTRAINTS.node.key" :constraint="CONSTRAINTS.node.key"
@ -159,6 +162,7 @@ async function onSubmit() {
/> />
<ValidationFormInput <ValidationFormInput
v-model="macModel" v-model="macModel"
name="mac"
label="MAC-Adresse" label="MAC-Adresse"
placeholder="z. B. 12:34:56:78:9a:bc oder 123456789abc" placeholder="z. B. 12:34:56:78:9a:bc oder 123456789abc"
:constraint="CONSTRAINTS.node.mac" :constraint="CONSTRAINTS.node.mac"
@ -167,7 +171,11 @@ async function onSubmit() {
/> />
</fieldset> </fieldset>
<h1>TODO: Standort</h1> <fieldset>
<h3>Wo soll Dein Router stehen?</h3>
<NodeCoordinatesInput v-model="coordsModel" />
</fieldset>
<fieldset> <fieldset>
<h3>Wie können wir Dich erreichen?</h3> <h3>Wie können wir Dich erreichen?</h3>
@ -186,6 +194,7 @@ async function onSubmit() {
<ValidationFormInput <ValidationFormInput
v-model="nicknameModel" v-model="nicknameModel"
name="nickname"
label="Nickname / Name" label="Nickname / Name"
placeholder="z. B. Lisa" placeholder="z. B. Lisa"
:constraint="CONSTRAINTS.node.nickname" :constraint="CONSTRAINTS.node.nickname"
@ -193,6 +202,7 @@ async function onSubmit() {
/> />
<ValidationFormInput <ValidationFormInput
v-model="emailModel" v-model="emailModel"
name="email"
type="email" type="email"
label="E-Mail-Adresse" label="E-Mail-Adresse"
:placeholder="`z. B. lisa@${configStore.getConfig.community.domain}`" :placeholder="`z. B. lisa@${configStore.getConfig.community.domain}`"
@ -201,7 +211,11 @@ async function onSubmit() {
/> />
</fieldset> </fieldset>
<h1>TODO: Monitoring</h1> <fieldset>
<h3>TODO: Monitoring</h3>
</fieldset>
<h1>TODO: Check community bounds</h1>
<ButtonGroup <ButtonGroup
:align="ComponentAlignment.RIGHT" :align="ComponentAlignment.RIGHT"

View file

@ -87,6 +87,7 @@ async function onSubmit() {
<fieldset> <fieldset>
<ValidationFormInput <ValidationFormInput
class="token-input" class="token-input"
name="token"
v-model="token" v-model="token"
label="Token" label="Token"
placeholder="Dein 16-stelliger Token" placeholder="Dein 16-stelliger Token"

View file

@ -82,7 +82,7 @@ $link-focus-outline: 0.1em solid $link-hover-color;
// Form // Form
$label-font-weight: 600; $label-font-weight: 600;
$fieldset-margin: 0; $fieldset-margin: 1em 0;
$fieldset-padding: 0.5em 0.75em; $fieldset-padding: 0.5em 0.75em;
$fieldset-border: 0.1em solid $gray; $fieldset-border: 0.1em solid $gray;
$fieldset-border-radius: 0.5em; $fieldset-border-radius: 0.5em;
@ -141,6 +141,11 @@ $node-preview-card-map-margin: 1em 0 0.25em 0;
$node-preview-card-field-margin: 0.25em 0; $node-preview-card-field-margin: 0.25em 0;
$node-preview-card-field-code-font-size: 1.25em; $node-preview-card-field-code-font-size: 1.25em;
// Node coordinates input
$node-coordinates-input-margin: 1em 0;
$node-coordinates-input-input-panel-width: 40%;
$node-coordinates-input-horizontal-gap: 1em;
// Created node summary // Created node summary
$node-created-summary-padding: $node-preview-card-padding; $node-created-summary-padding: $node-preview-card-padding;
$node-created-summary-background-color: darken($variant-color-success, 30%); $node-created-summary-background-color: darken($variant-color-success, 30%);