Allow picking coordinates from map.
This commit is contained in:
parent
56e247e031
commit
b5eaf0b637
6 changed files with 205 additions and 15 deletions
|
@ -1,21 +1,32 @@
|
|||
<script setup lang="ts">
|
||||
import "leaflet/dist/leaflet.css";
|
||||
import { onMounted, ref } from "vue";
|
||||
import { onMounted, ref, watch } from "vue";
|
||||
import { useConfigStore } from "@/stores/config";
|
||||
import type { Coordinates } from "@/types";
|
||||
import * as L from "leaflet";
|
||||
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 configStore = useConfigStore();
|
||||
|
||||
interface Props {
|
||||
coordinates?: Coordinates;
|
||||
editable?: boolean;
|
||||
}
|
||||
|
||||
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);
|
||||
watch(props, updateMarker);
|
||||
|
||||
function getLayers(): {
|
||||
layers: { [name: string]: L.Layer };
|
||||
|
@ -41,6 +52,18 @@ function getLayers(): {
|
|||
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 }) {
|
||||
const element = wrapper.value;
|
||||
if (!element) {
|
||||
|
@ -57,27 +80,64 @@ function createMap(defaultLayers: L.Layer[], layers: { [p: string]: L.Layer }) {
|
|||
attributionControl: true,
|
||||
layers: defaultLayers,
|
||||
};
|
||||
const map = L.map(element, options);
|
||||
map = L.map(element, options);
|
||||
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;
|
||||
|
||||
if (props.coordinates) {
|
||||
[lat, lng] = props.coordinates.split(" ").map(parseToFloat);
|
||||
const coordinates = getCoordinates();
|
||||
|
||||
if (coordinates) {
|
||||
[lat, lng] = coordinates;
|
||||
zoom = map.getMaxZoom();
|
||||
L.marker([lat, lng], {}).addTo(map);
|
||||
}
|
||||
map.setView([lat, lng], zoom);
|
||||
}
|
||||
|
||||
function renderMap() {
|
||||
const { layers, defaultLayers } = getLayers();
|
||||
const map = createMap(defaultLayers, layers);
|
||||
centerOnCoordinates(map);
|
||||
createMap(defaultLayers, layers);
|
||||
centerOnCoordinates();
|
||||
updateMarker();
|
||||
}
|
||||
|
||||
function onClick(event: L.LeafletMouseEvent) {
|
||||
emit(
|
||||
"coordinatesSelected",
|
||||
`${event.latlng.lat} ${event.latlng.lng}` as Coordinates
|
||||
);
|
||||
}
|
||||
</script>
|
||||
|
||||
|
|
|
@ -4,8 +4,9 @@ import { type Constraint, forConstraint } from "@/shared/validation/validator";
|
|||
import ExpandableHelpBox from "@/components/ExpandableHelpBox.vue";
|
||||
|
||||
interface Props {
|
||||
name: string;
|
||||
modelValue?: string;
|
||||
label: string;
|
||||
label?: string;
|
||||
type?: string;
|
||||
placeholder: string;
|
||||
constraint: Constraint;
|
||||
|
@ -19,7 +20,11 @@ const emit = defineEmits<{
|
|||
}>();
|
||||
|
||||
const displayLabel = computed(() =>
|
||||
props.constraint.optional ? props.label : `${props.label}*`
|
||||
props.label
|
||||
? props.constraint.optional
|
||||
? props.label
|
||||
: `${props.label}*`
|
||||
: undefined
|
||||
);
|
||||
|
||||
const input = ref<HTMLInputElement>();
|
||||
|
@ -75,17 +80,27 @@ onMounted(() => {
|
|||
|
||||
<template>
|
||||
<div class="validation-form-input">
|
||||
<label>
|
||||
<label v-if="displayLabel">
|
||||
{{ displayLabel }}:
|
||||
<ExpandableHelpBox v-if="props.help" :text="props.help" />
|
||||
<input
|
||||
ref="input"
|
||||
:name="props.name"
|
||||
:value="props.modelValue"
|
||||
@input="onInput"
|
||||
:type="props.type || 'text'"
|
||||
:placeholder="props.placeholder"
|
||||
/>
|
||||
</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">
|
||||
{{ props.validationError }}
|
||||
</div>
|
||||
|
|
95
frontend/src/components/nodes/NodeCoordinatesInput.vue
Normal file
95
frontend/src/components/nodes/NodeCoordinatesInput.vue
Normal 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>
|
|
@ -26,6 +26,7 @@ import ValidationFormInput from "@/components/form/ValidationFormInput.vue";
|
|||
import { route, RouteName } from "@/router";
|
||||
import RouteButton from "@/components/form/RouteButton.vue";
|
||||
import { ApiError } from "@/utils/Api";
|
||||
import NodeCoordinatesInput from "@/components/nodes/NodeCoordinatesInput.vue";
|
||||
|
||||
const configStore = useConfigStore();
|
||||
const nodeStore = useNodeStore();
|
||||
|
@ -143,6 +144,7 @@ async function onSubmit() {
|
|||
|
||||
<ValidationFormInput
|
||||
v-model="hostnameModel"
|
||||
name="hostname"
|
||||
label="Knotenname"
|
||||
placeholder="z. B. Lisas-Freifunk"
|
||||
:constraint="CONSTRAINTS.node.hostname"
|
||||
|
@ -151,6 +153,7 @@ async function onSubmit() {
|
|||
/>
|
||||
<ValidationFormInput
|
||||
v-model="fastdKeyModel"
|
||||
name="key"
|
||||
label="VPN-Schlüssel (bitte nur weglassen, wenn Du weisst, was Du tust)"
|
||||
placeholder="Dein 64-stelliger VPN-Schlüssel"
|
||||
:constraint="CONSTRAINTS.node.key"
|
||||
|
@ -159,6 +162,7 @@ async function onSubmit() {
|
|||
/>
|
||||
<ValidationFormInput
|
||||
v-model="macModel"
|
||||
name="mac"
|
||||
label="MAC-Adresse"
|
||||
placeholder="z. B. 12:34:56:78:9a:bc oder 123456789abc"
|
||||
:constraint="CONSTRAINTS.node.mac"
|
||||
|
@ -167,7 +171,11 @@ async function onSubmit() {
|
|||
/>
|
||||
</fieldset>
|
||||
|
||||
<h1>TODO: Standort</h1>
|
||||
<fieldset>
|
||||
<h3>Wo soll Dein Router stehen?</h3>
|
||||
|
||||
<NodeCoordinatesInput v-model="coordsModel" />
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<h3>Wie können wir Dich erreichen?</h3>
|
||||
|
@ -186,6 +194,7 @@ async function onSubmit() {
|
|||
|
||||
<ValidationFormInput
|
||||
v-model="nicknameModel"
|
||||
name="nickname"
|
||||
label="Nickname / Name"
|
||||
placeholder="z. B. Lisa"
|
||||
:constraint="CONSTRAINTS.node.nickname"
|
||||
|
@ -193,6 +202,7 @@ async function onSubmit() {
|
|||
/>
|
||||
<ValidationFormInput
|
||||
v-model="emailModel"
|
||||
name="email"
|
||||
type="email"
|
||||
label="E-Mail-Adresse"
|
||||
:placeholder="`z. B. lisa@${configStore.getConfig.community.domain}`"
|
||||
|
@ -201,7 +211,11 @@ async function onSubmit() {
|
|||
/>
|
||||
</fieldset>
|
||||
|
||||
<h1>TODO: Monitoring</h1>
|
||||
<fieldset>
|
||||
<h3>TODO: Monitoring</h3>
|
||||
</fieldset>
|
||||
|
||||
<h1>TODO: Check community bounds</h1>
|
||||
|
||||
<ButtonGroup
|
||||
:align="ComponentAlignment.RIGHT"
|
||||
|
|
|
@ -87,6 +87,7 @@ async function onSubmit() {
|
|||
<fieldset>
|
||||
<ValidationFormInput
|
||||
class="token-input"
|
||||
name="token"
|
||||
v-model="token"
|
||||
label="Token"
|
||||
placeholder="Dein 16-stelliger Token"
|
||||
|
|
|
@ -82,7 +82,7 @@ $link-focus-outline: 0.1em solid $link-hover-color;
|
|||
|
||||
// Form
|
||||
$label-font-weight: 600;
|
||||
$fieldset-margin: 0;
|
||||
$fieldset-margin: 1em 0;
|
||||
$fieldset-padding: 0.5em 0.75em;
|
||||
$fieldset-border: 0.1em solid $gray;
|
||||
$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-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
|
||||
$node-created-summary-padding: $node-preview-card-padding;
|
||||
$node-created-summary-background-color: darken($variant-color-success, 30%);
|
||||
|
|
Loading…
Reference in a new issue