facilmap/frontend/src/lib/components/line-info/line-info.vue

287 wiersze
8.6 KiB
Vue

<script setup lang="ts">
import type { ExportFormat, ID } from "facilmap-types";
import EditLineDialog from "../edit-line-dialog.vue";
import ElevationStats from "../ui/elevation-stats.vue";
import ElevationPlot from "../ui/elevation-plot.vue";
import Icon from "../ui/icon.vue";
import { getZoomDestinationForLine } from "../../utils/zoom";
import RouteForm from "../route-form/route-form.vue";
import vTooltip from "../../utils/tooltip";
import { formatDistance, formatFieldName, formatFieldValue, formatRouteTime, formatTypeName, normalizeLineName } from "facilmap-utils";
import { computed, reactive, ref, toRef } from "vue";
import { useToasts } from "../ui/toasts/toasts.vue";
import { showConfirm } from "../ui/alert.vue";
import ZoomToObjectButton from "../ui/zoom-to-object-button.vue";
import { injectContextRequired, requireClientContext, requireMapContext } from "../facil-map-context-provider/facil-map-context-provider.vue";
import ExportDropdown from "../ui/export-dropdown.vue";
import { useI18n } from "../../utils/i18n";
import DropdownMenu from "../ui/dropdown-menu.vue";
const context = injectContextRequired();
const client = requireClientContext(context);
const mapContext = requireMapContext(context);
const toasts = useToasts();
const i18n = useI18n();
const props = withDefaults(defineProps<{
lineId: ID;
showBackButton?: boolean;
}>(), {
showBackButton: false
});
const emit = defineEmits<{
back: [];
}>();
const routeForm = ref<InstanceType<typeof RouteForm>>();
const showEditDialog = ref(false);
const isDeleting = ref(false);
const showElevationPlot = ref(false);
const isMoving = ref(false);
const line = computed(() => client.value.lines[props.lineId]);
const typeName = computed(() => formatTypeName(client.value.types[line.value.typeId].name));
const showTypeName = computed(() => Object.values(client.value.types).filter((t) => t.type === 'line').length > 1);
async function deleteLine(): Promise<void> {
toasts.hideToast(`fm${context.id}-line-info-delete`);
if (!await showConfirm({
title: i18n.t("line-info.delete-line-title"),
message: i18n.t("line-info.delete-line-message", { name: normalizeLineName(line.value.name) }),
variant: "danger",
okLabel: i18n.t("line-info.delete-line-ok")
}))
return;
isDeleting.value = true;
try {
await client.value.deleteLine({ id: props.lineId });
} catch (err) {
toasts.showErrorToast(`fm${context.id}-line-info-delete`, () => i18n.t("line-info.delete-line-error"), err);
} finally {
isDeleting.value = false;
}
}
async function getExport(format: ExportFormat): Promise<string> {
return await client.value.exportLine({ id: line.value.id, format });
}
async function moveLine(): Promise<void> {
toasts.hideToast(`fm${context.id}-line-info-move`);
mapContext.value.components.map.fire('fmInteractionStart');
const routeId = `l${line.value.id}`;
try {
await client.value.lineToRoute({ id: line.value.id, routeId });
mapContext.value.components.linesLayer.hideLine(line.value.id);
const isSaving = ref(false);
const done = async (save: boolean) => {
const route = client.value.routes[routeId];
if (save && !route)
return;
try {
if(save) {
isSaving.value = true;
await client.value.editLine({ id: line.value.id, routePoints: route.routePoints, mode: route.mode });
}
toasts.hideToast(`fm${context.id}-line-info-move`);
} catch (err) {
toasts.showErrorToast(`fm${context.id}-line-info-move`, () => i18n.t("line-info.save-line-error"), err);
} finally {
mapContext.value.components.map.fire('fmInteractionEnd');
isMoving.value = false;
// Clear route after editing line so that the server can take the trackPoints from the route
client.value.clearRoute({ routeId }).catch((err) => {
console.error("Error clearing route", err);
});
mapContext.value.components.linesLayer.unhideLine(line.value.id);
}
};
toasts.showToast(`fm${context.id}-line-info-move`, () => i18n.t("line-info.move-line-title"), () => i18n.t("line-info.move-line-message"), reactive({
noCloseButton: true,
actions: toRef(() => [
{
label: i18n.t("line-info.move-line-finish"),
variant: "primary" as const,
onClick: () => { void done(true); },
isPending: isSaving.value,
isDisabled: isSaving.value
},
{
label: i18n.t("line-info.move-line-cancel"),
onClick: () => { void done(false); },
isDisabled: isSaving.value
}
])
}));
isMoving.value = true;
} catch (err) {
toasts.showErrorToast(`fm${context.id}-line-info-move-error`, () => i18n.t("line-info.save-line-error"), err);
toasts.hideToast(`fm${context.id}-line-info-move`);
mapContext.value.components.map.fire('fmInteractionEnd');
isMoving.value = false;
client.value.clearRoute({ routeId }).catch((err) => {
console.error("Error clearing route", err);
});
mapContext.value.components.linesLayer.unhideLine(line.value.id);
}
}
const zoomDestination = computed(() => getZoomDestinationForLine(line.value));
</script>
<template>
<div class="fm-line-info" v-if="line">
<div class="d-flex align-items-center">
<h2 class="flex-grow-1 text-break">
<a v-if="showBackButton" href="javascript:" @click="emit('back')"><Icon icon="arrow-left"></Icon></a>
<span>
{{normalizeLineName(line.name)}}
<template v-if="showTypeName">
<span class="type-name">({{typeName}})</span>
</template>
</span>
</h2>
<div v-if="!isMoving" class="btn-toolbar">
<button
v-if="line.ascent != null"
type="button"
class="btn btn-secondary"
:class="{ active: showElevationPlot }"
@click="showElevationPlot = !showElevationPlot"
v-tooltip.right="showElevationPlot ? i18n.t('line-info.hide-elevation-plot') : i18n.t('line-info.show-elevation-plot')"
>
<Icon icon="chart-line" :alt="showElevationPlot ? i18n.t('line-info.hide-elevation-plot') : i18n.t('line-info.show-elevation-plot')"></Icon>
</button>
</div>
</div>
<div class="fm-search-box-collapse-point" v-if="!isMoving">
<dl class="fm-search-box-dl">
<dt class="distance">{{i18n.t("line-info.distance")}}</dt>
<dd class="distance">{{formatDistance(line.distance)}} <span v-if="line.time != null">({{formatRouteTime(line.time, line.mode)}})</span></dd>
<template v-if="line.ascent != null">
<dt class="elevation">{{i18n.t("line-info.ascent-descent")}}</dt>
<dd class="elevation"><ElevationStats :route="line"></ElevationStats></dd>
</template>
<template v-if="line.ascent == null || !showElevationPlot">
<template v-for="field in client.types[line.typeId].fields" :key="field.name">
<dt>{{formatFieldName(field.name)}}</dt>
<dd v-html="formatFieldValue(field, line.data[field.name], true)"></dd>
</template>
</template>
</dl>
<ElevationPlot :route="line" v-if="line.ascent != null && showElevationPlot"></ElevationPlot>
</div>
<div v-if="!isMoving" class="btn-toolbar">
<ZoomToObjectButton
v-if="zoomDestination"
:label="i18n.t('line-info.zoom-to-object-label')"
size="sm"
:destination="zoomDestination"
></ZoomToObjectButton>
<ExportDropdown
:filename="normalizeLineName(line.name)"
:getExport="getExport"
size="sm"
></ExportDropdown>
<button
v-if="!client.readonly"
type="button"
class="btn btn-secondary btn-sm"
size="sm"
@click="showEditDialog = true"
:disabled="isDeleting || mapContext.interaction"
>{{i18n.t("line-info.edit-data")}}</button>
<DropdownMenu
v-if="!client.readonly"
size="sm"
:label="i18n.t('line-info.actions')"
:isBusy="isDeleting"
:isDisabled="mapContext.interaction"
>
<li>
<a
v-if="line.mode != 'track'"
href="javascript:"
class="dropdown-item"
@click="moveLine()"
>{{i18n.t("line-info.edit-waypoints")}}</a>
</li>
<li>
<a
href="javascript:"
class="dropdown-item"
@click="deleteLine()"
>{{i18n.t("line-info.delete")}}</a>
</li>
</DropdownMenu>
</div>
<RouteForm
v-if="isMoving"
active
ref="routeForm"
:routeId="`l${line.id}`"
:showToolbar="false"
noClear
></RouteForm>
<EditLineDialog
v-if="showEditDialog"
:lineId="lineId"
@hidden="showEditDialog = false"
></EditLineDialog>
</div>
</template>
<style lang="scss">
.fm-line-info {
display: flex;
flex-direction: column;
min-height: 0;
flex-grow: 1;
.fm-search-box-collapse-point {
display: flex;
flex-direction: column;
min-height: 1.5em;
flex-grow: 1;
}
.type-name {
color: #888;
font-size: 0.7em;
}
.fm-elevation-plot {
margin-bottom: 0.5rem;
}
}
</style>