Show node on map in confirmation before deleting it.
This commit is contained in:
parent
3b79e807ba
commit
aa0d63fd44
10 changed files with 205 additions and 8 deletions
|
@ -17,6 +17,7 @@
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"fork-awesome": "^1.2.0",
|
"fork-awesome": "^1.2.0",
|
||||||
|
"leaflet": "^1.8.0",
|
||||||
"pinia": "^2.0.20",
|
"pinia": "^2.0.20",
|
||||||
"sparkson": "^1.3.6",
|
"sparkson": "^1.3.6",
|
||||||
"vue": "^3.2.37",
|
"vue": "^3.2.37",
|
||||||
|
@ -25,6 +26,7 @@
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@rushstack/eslint-patch": "^1.1.4",
|
"@rushstack/eslint-patch": "^1.1.4",
|
||||||
"@types/jsdom": "^20.0.0",
|
"@types/jsdom": "^20.0.0",
|
||||||
|
"@types/leaflet": "^1.7.11",
|
||||||
"@types/node": "^18.7.13",
|
"@types/node": "^18.7.13",
|
||||||
"@vitejs/plugin-basic-ssl": "^0.1.2",
|
"@vitejs/plugin-basic-ssl": "^0.1.2",
|
||||||
"@vitejs/plugin-vue": "^3.0.3",
|
"@vitejs/plugin-vue": "^3.0.3",
|
||||||
|
|
|
@ -28,6 +28,9 @@ body {
|
||||||
}
|
}
|
||||||
|
|
||||||
main {
|
main {
|
||||||
|
position: relative;
|
||||||
|
z-index: 0;
|
||||||
|
|
||||||
padding-top: $page-padding-top;
|
padding-top: $page-padding-top;
|
||||||
padding-bottom: $page-padding-bottom;
|
padding-bottom: $page-padding-bottom;
|
||||||
|
|
||||||
|
|
109
frontend/src/components/NodeMap.vue
Normal file
109
frontend/src/components/NodeMap.vue
Normal file
|
@ -0,0 +1,109 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import "leaflet/dist/leaflet.css";
|
||||||
|
import { onMounted, ref } from "vue";
|
||||||
|
import { useConfigStore } from "@/stores/config";
|
||||||
|
import type { Coordinates } from "@/types";
|
||||||
|
import * as L from "leaflet";
|
||||||
|
import { parseToFloat } from "@/utils/Numbers";
|
||||||
|
|
||||||
|
const wrapper = ref<HTMLElement>();
|
||||||
|
const configStore = useConfigStore();
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
coordinates?: Coordinates;
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<Props>();
|
||||||
|
|
||||||
|
onMounted(renderMap);
|
||||||
|
|
||||||
|
function getLayers(): {
|
||||||
|
layers: { [name: string]: L.Layer };
|
||||||
|
defaultLayers: L.Layer[];
|
||||||
|
} {
|
||||||
|
const layers: { [name: string]: L.Layer } = {};
|
||||||
|
const defaultLayers: L.Layer[] = [];
|
||||||
|
for (const [id, layerCfg] of Object.entries(
|
||||||
|
configStore.getConfig.coordsSelector.layers
|
||||||
|
)) {
|
||||||
|
const layerOptions = layerCfg.layerOptions;
|
||||||
|
|
||||||
|
const layer = L.tileLayer(layerCfg.url, {
|
||||||
|
id,
|
||||||
|
...layerOptions,
|
||||||
|
});
|
||||||
|
|
||||||
|
layers[layerCfg.name] = layer;
|
||||||
|
if (defaultLayers.length === 0) {
|
||||||
|
defaultLayers.push(layer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { layers, defaultLayers };
|
||||||
|
}
|
||||||
|
|
||||||
|
function createMap(defaultLayers: L.Layer[], layers: { [p: string]: L.Layer }) {
|
||||||
|
const element = wrapper.value;
|
||||||
|
if (!element) {
|
||||||
|
throw new Error("Illegal state: Map wrapper element not set.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const options: L.MapOptions = {
|
||||||
|
renderer: new L.Canvas(),
|
||||||
|
minZoom: 2,
|
||||||
|
crs: L.CRS.EPSG3857,
|
||||||
|
zoomControl: true,
|
||||||
|
worldCopyJump: true,
|
||||||
|
maxBounds: L.latLngBounds([90, -540.5], [-90, 540.5]),
|
||||||
|
attributionControl: true,
|
||||||
|
layers: defaultLayers,
|
||||||
|
};
|
||||||
|
const map = L.map(element, options);
|
||||||
|
L.control.layers(layers).addTo(map);
|
||||||
|
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
function centerOnCoordinates(map: L.Map) {
|
||||||
|
let { lat, lng, defaultZoom: zoom } = configStore.getConfig.coordsSelector;
|
||||||
|
|
||||||
|
if (props.coordinates) {
|
||||||
|
[lat, lng] = props.coordinates.split(" ").map(parseToFloat);
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="map-container">
|
||||||
|
<div class="map" ref="wrapper"></div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
@use "sass:math";
|
||||||
|
@import "../scss/variables";
|
||||||
|
.map-container {
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
width: 100%;
|
||||||
|
padding-top: math.percentage(math.div(1, $node-map-aspect-ratio));
|
||||||
|
|
||||||
|
.map {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
|
||||||
|
border-radius: $node-map-border-radius;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
|
@ -103,6 +103,9 @@ async function onAbort() {
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
.preview {
|
.preview {
|
||||||
margin: 1em 0;
|
margin: {
|
||||||
|
top: 1em;
|
||||||
|
bottom: 1em;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { StoredNode } from "@/types";
|
import type { StoredNode } from "@/types";
|
||||||
import { MonitoringState } from "@/types";
|
import { MonitoringState } from "@/types";
|
||||||
|
import NodeMap from "@/components/NodeMap.vue";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
node: StoredNode;
|
node: StoredNode;
|
||||||
|
@ -54,11 +55,21 @@ const props = defineProps<Props>();
|
||||||
nicht aktiv
|
nicht aktiv
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<NodeMap class="node-map" :coordinates="props.node.coords" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
@import "../../scss/variables";
|
@import "../../scss/variables";
|
||||||
|
@import "../../scss/mixins";
|
||||||
|
|
||||||
|
@include min-page-breakpoint(medium) {
|
||||||
|
.node-preview-card {
|
||||||
|
max-width: nth(map-get($page-breakpoints, small), 1);
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.node-preview-card {
|
.node-preview-card {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
@ -76,6 +87,10 @@ const props = defineProps<Props>();
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.node-map {
|
||||||
|
margin: $node-preview-card-map-margin;
|
||||||
|
}
|
||||||
|
|
||||||
.field {
|
.field {
|
||||||
margin: $node-preview-card-field-margin;
|
margin: $node-preview-card-field-margin;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
|
|
|
@ -120,12 +120,17 @@ $error-card-link-color: darken($variant-color-danger, 30%);
|
||||||
$error-card-link-hover-color: $error-card-link-color;
|
$error-card-link-hover-color: $error-card-link-color;
|
||||||
$error-card-link-focus-outline: 0.1em solid $error-card-link-hover-color;
|
$error-card-link-focus-outline: 0.1em solid $error-card-link-hover-color;
|
||||||
|
|
||||||
|
// Node map
|
||||||
|
$node-map-aspect-ratio: 16 / 10;
|
||||||
|
$node-map-border-radius: 0.75em;
|
||||||
|
|
||||||
// Node preview
|
// Node preview
|
||||||
$node-preview-card-padding: 0.75em 1em;
|
$node-preview-card-padding: 0.75em 1em;
|
||||||
$node-preview-card-border: 0.2em solid $gray;
|
$node-preview-card-border: 0.2em solid $gray;
|
||||||
$node-preview-card-border-radius: 1em;
|
$node-preview-card-border-radius: 1em;
|
||||||
$node-preview-card-headline-font-size: 1.5em;
|
$node-preview-card-headline-font-size: 1.5em;
|
||||||
$node-preview-card-headline-margin: 0 0 0.5em;
|
$node-preview-card-headline-margin: 0 0 0.5em;
|
||||||
|
$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;
|
||||||
|
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import { SortDirection, toIsArray, type TypeGuard } from "@/types";
|
import { SortDirection, toIsArray, type TypeGuard } from "@/types";
|
||||||
import type { Headers } from "request";
|
import type { Headers } from "request";
|
||||||
import { parseInteger } from "@/utils/Numbers";
|
import { parseToInteger } from "@/utils/Numbers";
|
||||||
|
|
||||||
type Method = "GET" | "PUT" | "POST" | "DELETE";
|
type Method = "GET" | "PUT" | "POST" | "DELETE";
|
||||||
|
|
||||||
|
@ -172,7 +172,7 @@ class Api {
|
||||||
...filter,
|
...filter,
|
||||||
});
|
});
|
||||||
const totalStr = response.headers.get("x-total-count");
|
const totalStr = response.headers.get("x-total-count");
|
||||||
const total = parseInteger(totalStr, 10);
|
const total = parseToInteger(totalStr, 10);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
entries: response.result,
|
entries: response.result,
|
||||||
|
|
|
@ -1,9 +1,12 @@
|
||||||
export function isInteger(arg: unknown): arg is number {
|
import { isNumber } from "@/shared/types";
|
||||||
return typeof arg === "number" && Number.isInteger(arg);
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO: Write tests!
|
// TODO: Write tests!
|
||||||
export function parseInteger(arg: unknown, radix: number): number {
|
|
||||||
|
export function isInteger(arg: unknown): arg is number {
|
||||||
|
return isNumber(arg) && Number.isInteger(arg);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseToInteger(arg: unknown, radix: number): number {
|
||||||
if (isInteger(arg)) {
|
if (isInteger(arg)) {
|
||||||
return arg;
|
return arg;
|
||||||
}
|
}
|
||||||
|
@ -21,7 +24,7 @@ export function parseInteger(arg: unknown, radix: number): number {
|
||||||
}
|
}
|
||||||
if (num.toString(radix).toLowerCase() !== str.toLowerCase()) {
|
if (num.toString(radix).toLowerCase() !== str.toLowerCase()) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Parsed integer does not match given string (radix: {radix}): ${str}`
|
`Parsed integer does not match given string (radix: ${radix}): ${str}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return num;
|
return num;
|
||||||
|
@ -30,3 +33,39 @@ export function parseInteger(arg: unknown, radix: number): number {
|
||||||
throw new Error(`Cannot parse number (radix: ${radix}): ${arg}`);
|
throw new Error(`Cannot parse number (radix: ${radix}): ${arg}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isFloat(arg: unknown): arg is number {
|
||||||
|
return isNumber(arg) && Number.isFinite(arg);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseToFloat(arg: unknown): number {
|
||||||
|
if (isFloat(arg)) {
|
||||||
|
return arg;
|
||||||
|
}
|
||||||
|
switch (typeof arg) {
|
||||||
|
case "number":
|
||||||
|
throw new Error(`Not a finite number: ${arg}`);
|
||||||
|
case "string": {
|
||||||
|
let str = (arg as string).trim();
|
||||||
|
const num = parseFloat(str);
|
||||||
|
if (isNaN(num)) {
|
||||||
|
throw new Error(`Not a valid number: ${str}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Number.isInteger(num)) {
|
||||||
|
str = str.replace(/\.0+$/, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (num.toString(10) !== str) {
|
||||||
|
throw new Error(
|
||||||
|
`Parsed float does not match given string: ${num.toString(
|
||||||
|
10
|
||||||
|
)} !== ${str}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return num;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
throw new Error(`Cannot parse number: ${arg}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -89,6 +89,11 @@
|
||||||
resolved "https://registry.yarnpkg.com/@types/chai/-/chai-4.3.3.tgz#3c90752792660c4b562ad73b3fbd68bf3bc7ae07"
|
resolved "https://registry.yarnpkg.com/@types/chai/-/chai-4.3.3.tgz#3c90752792660c4b562ad73b3fbd68bf3bc7ae07"
|
||||||
integrity sha512-hC7OMnszpxhZPduX+m+nrx+uFoLkWOMiR4oa/AZF3MuSETYTZmFfJAHqZEM8MVlvfG7BEUcgvtwoCTxBp6hm3g==
|
integrity sha512-hC7OMnszpxhZPduX+m+nrx+uFoLkWOMiR4oa/AZF3MuSETYTZmFfJAHqZEM8MVlvfG7BEUcgvtwoCTxBp6hm3g==
|
||||||
|
|
||||||
|
"@types/geojson@*":
|
||||||
|
version "7946.0.10"
|
||||||
|
resolved "https://registry.yarnpkg.com/@types/geojson/-/geojson-7946.0.10.tgz#6dfbf5ea17142f7f9a043809f1cd4c448cb68249"
|
||||||
|
integrity sha512-Nmh0K3iWQJzniTuPRcJn5hxXkfB1T1pgB89SBig5PlJQU5yocazeu4jATJlaA0GYFKWMqDdvYemoSnF2pXgLVA==
|
||||||
|
|
||||||
"@types/jsdom@^20.0.0":
|
"@types/jsdom@^20.0.0":
|
||||||
version "20.0.0"
|
version "20.0.0"
|
||||||
resolved "https://registry.yarnpkg.com/@types/jsdom/-/jsdom-20.0.0.tgz#4414fb629465167f8b7b3804b9e067bdd99f1791"
|
resolved "https://registry.yarnpkg.com/@types/jsdom/-/jsdom-20.0.0.tgz#4414fb629465167f8b7b3804b9e067bdd99f1791"
|
||||||
|
@ -103,6 +108,13 @@
|
||||||
resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.11.tgz#d421b6c527a3037f7c84433fd2c4229e016863d3"
|
resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.11.tgz#d421b6c527a3037f7c84433fd2c4229e016863d3"
|
||||||
integrity sha512-wOuvG1SN4Us4rez+tylwwwCV1psiNVOkJeM3AUWUNWg/jDQY2+HE/444y5gc+jBmRqASOm2Oeh5c1axHobwRKQ==
|
integrity sha512-wOuvG1SN4Us4rez+tylwwwCV1psiNVOkJeM3AUWUNWg/jDQY2+HE/444y5gc+jBmRqASOm2Oeh5c1axHobwRKQ==
|
||||||
|
|
||||||
|
"@types/leaflet@^1.7.11":
|
||||||
|
version "1.7.11"
|
||||||
|
resolved "https://registry.yarnpkg.com/@types/leaflet/-/leaflet-1.7.11.tgz#48b33b7a15b015bbb1e8950399298a112c3220c8"
|
||||||
|
integrity sha512-VwAYom2pfIAf/pLj1VR5aLltd4tOtHyvfaJlNYCoejzP2nu52PrMi1ehsLRMUS+bgafmIIKBV1cMfKeS+uJ0Vg==
|
||||||
|
dependencies:
|
||||||
|
"@types/geojson" "*"
|
||||||
|
|
||||||
"@types/node@*":
|
"@types/node@*":
|
||||||
version "17.0.34"
|
version "17.0.34"
|
||||||
resolved "https://registry.yarnpkg.com/@types/node/-/node-17.0.34.tgz#3b0b6a50ff797280b8d000c6281d229f9c538cef"
|
resolved "https://registry.yarnpkg.com/@types/node/-/node-17.0.34.tgz#3b0b6a50ff797280b8d000c6281d229f9c538cef"
|
||||||
|
@ -1437,6 +1449,11 @@ json-stable-stringify-without-jsonify@^1.0.1:
|
||||||
resolved "https://registry.yarnpkg.com/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz#9db7b59496ad3f3cfef30a75142d2d930ad72651"
|
resolved "https://registry.yarnpkg.com/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz#9db7b59496ad3f3cfef30a75142d2d930ad72651"
|
||||||
integrity sha1-nbe1lJatPzz+8wp1FC0tkwrXJlE=
|
integrity sha1-nbe1lJatPzz+8wp1FC0tkwrXJlE=
|
||||||
|
|
||||||
|
leaflet@^1.8.0:
|
||||||
|
version "1.8.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/leaflet/-/leaflet-1.8.0.tgz#4615db4a22a304e8e692cae9270b983b38a2055e"
|
||||||
|
integrity sha512-gwhMjFCQiYs3x/Sf+d49f10ERXaEFCPr+nVTryhAW8DWbMGqJqt9G4XuIaHmFW08zYvhgdzqXGr8AlW8v8dQkA==
|
||||||
|
|
||||||
levn@^0.4.1:
|
levn@^0.4.1:
|
||||||
version "0.4.1"
|
version "0.4.1"
|
||||||
resolved "https://registry.yarnpkg.com/levn/-/levn-0.4.1.tgz#ae4562c007473b932a6200d403268dd2fffc6ade"
|
resolved "https://registry.yarnpkg.com/levn/-/levn-0.4.1.tgz#ae4562c007473b932a6200d403268dd2fffc6ade"
|
||||||
|
|
|
@ -466,6 +466,10 @@ export const isHostname = toIsNewtype(isString, "" as Hostname);
|
||||||
export type Nickname = string & { readonly __tag: unique symbol };
|
export type Nickname = string & { readonly __tag: unique symbol };
|
||||||
export const isNickname = toIsNewtype(isString, "" as Nickname);
|
export const isNickname = toIsNewtype(isString, "" as Nickname);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* String representing geo coordinates. Latitude and longitude are delimited by one whitespace.
|
||||||
|
* E.g.: <code>"53.565278 10.001389"</code>
|
||||||
|
*/
|
||||||
export type Coordinates = string & { readonly __tag: unique symbol };
|
export type Coordinates = string & { readonly __tag: unique symbol };
|
||||||
export const isCoordinates = toIsNewtype(isString, "" as Coordinates);
|
export const isCoordinates = toIsNewtype(isString, "" as Coordinates);
|
||||||
|
|
||||||
|
|
Loading…
Reference in a new issue