changeset 4322:fabe67e204e7

bottleneckDialogue added
author Thomas Junk <thomas.junk@intevation.de>
date Wed, 04 Sep 2019 14:10:50 +0200
parents 6dfbf534818b
children ab7d80baebe6
files client/src/components/fairway/BottleneckDialogue.vue
diffstat 1 files changed, 702 insertions(+), 0 deletions(-) [+]
line wrap: on
line diff
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/client/src/components/fairway/BottleneckDialogue.vue	Wed Sep 04 14:10:50 2019 +0200
@@ -0,0 +1,702 @@
+<template>
+  <div
+    :class="[
+      'box ui-element rounded bg-white text-nowrap',
+      { expanded: showProfiles }
+    ]"
+  >
+    <div style="width: 18rem">
+      <UIBoxHeader
+        icon="chart-area"
+        :title="profilesLable"
+        :closeCallback="close"
+      />
+      <div class="box-body">
+        <UISpinnerOverlay v-if="surveysLoading || profileLoading" />
+        <select
+          @change="moveToBottleneck"
+          v-model="selectedBottleneck"
+          class="form-control font-weight-bold"
+        >
+          <option :value="null">
+            <translate>Select Bottleneck</translate>
+          </option>
+          <optgroup
+            v-for="(bottlenecksForCountry, cc) in orderedBottlenecks"
+            :key="cc"
+            :label="cc"
+          >
+            <option
+              v-for="bn in bottlenecksForCountry"
+              :key="bn.properties.id"
+              :value="bn.properties.name"
+            >
+              {{ bn.properties.name }}
+            </option>
+          </optgroup>
+        </select>
+        <div v-if="selectedBottleneck">
+          <div class="d-flex flex-column mt-2">
+            <div class="flex-fill">
+              <small class="text-muted">
+                <translate>Waterlevel</translate>:
+              </small>
+              <select
+                v-model="selectedWaterLevel"
+                class="form-control form-control-sm small"
+              >
+                <option value="ref">
+                  <translate>Depth Reference</translate>
+                  <template v-if="selectedSurvey">
+                    ({{ selectedSurvey.depth_reference }}/{{
+                      $options.filters.waterlevel(
+                        selectedSurvey.waterlevel_value
+                      )
+                    }}
+                    m)
+                  </template>
+                </option>
+                <option value="current">
+                  <translate>Current Waterlevel</translate>
+                  <template v-if="bottleneck">
+                    ({{
+                      $options.filters.waterlevel(
+                        bottleneck.get("gm_waterlevel")
+                      )
+                    }}
+                    m)
+                  </template>
+                </option>
+              </select>
+            </div>
+            <div class="flex-fill">
+              <small class="text-muted"> <translate>Survey</translate>: </small>
+              <div class="d-flex">
+                <select
+                  v-model="selectedSurvey"
+                  class="form-control form-control-sm small"
+                >
+                  <option
+                    v-for="survey in surveys"
+                    :key="survey.date_info"
+                    :value="survey"
+                    >{{ survey.date_info | surveyDate }}</option
+                  >
+                </select>
+                <button
+                  class="btn btn-dark btn-xs ml-2"
+                  @click="deleteSelectedSurvey"
+                >
+                  <font-awesome-icon icon="trash" />
+                </button>
+              </div>
+            </div>
+            <div class="flex-fill" v-if="selectedSurvey && surveys.length > 1">
+              <small class="text-muted mt-1">
+                <translate>Compare with</translate>:
+              </small>
+              <select
+                v-model="additionalSurvey"
+                class="form-control form-control-sm small"
+              >
+                <option :value="null">None</option>
+                <option
+                  v-for="survey in additionalSurveys"
+                  :key="survey.date_info"
+                  :value="survey"
+                  >{{ survey.date_info | surveyDate }}</option
+                >
+              </select>
+            </div>
+          </div>
+          <div class="mt-2 d-flex" v-if="additionalSurvey">
+            <button
+              v-if="differencesLoading"
+              class="btn btn-info btn-xs flex-fill"
+              disabled
+            >
+              <font-awesome-icon icon="spinner" spin class="mr-1" />
+              <translate>Calculating differences</translate>
+            </button>
+            <button
+              class="btn btn-info btn-xs flex-fill"
+              @click="differencesVisible ? showSurvey() : showDifferences()"
+              v-else
+            >
+              <translate v-if="differencesVisible" key="showsurvey"
+                >Show survey</translate
+              >
+              <translate v-else key="showdifferences"
+                >Show differences</translate
+              >
+            </button>
+            <button
+              v-if="!paneSetup.includes('FAIRWAYPROFILE')"
+              class="btn btn-info btn-xs ml-2"
+              @click="$store.commit('application/paneRotate')"
+              v-tooltip="rotatePanesTooltip"
+            >
+              <font-awesome-icon icon="redo" fixed-width />
+            </button>
+            <button
+              class="btn btn-info btn-xs ml-2"
+              @click="toggleSyncMaps()"
+              v-tooltip="syncMapsTooltip"
+            >
+              <font-awesome-icon
+                :icon="mapsAreSynced ? 'unlink' : 'link'"
+                fixed-width
+              />
+            </button>
+          </div>
+          <hr class="w-100 mb-0" />
+          <small class="text-muted d-block mt-2">
+            <translate>Saved cross profiles</translate>:
+          </small>
+          <div class="d-flex">
+            <select
+              :class="[
+                'form-control form-control-sm flex-fill',
+                { 'rounded-left-only': selectedCut }
+              ]"
+              v-model="selectedCut"
+            >
+              <option></option>
+              <option
+                v-for="(cut, index) in previousCuts"
+                :value="cut"
+                :key="index"
+                >{{ cut.label }}</option
+              >
+            </select>
+            <button
+              class="btn btn-xs btn-dark ml-2"
+              @click="deleteSelectedCut(selectedCut)"
+              v-if="selectedCut"
+            >
+              <font-awesome-icon icon="trash" />
+            </button>
+          </div>
+          <small class="text-muted d-block mt-2">
+            <translate>Enter coordinates manually</translate>:
+          </small>
+          <div class="position-relative">
+            <input
+              class="form-control form-control-sm pr-5"
+              placeholder="Lat,Lon,Lat,Lon"
+              v-model="coordinatesInput"
+            />
+            <button
+              class="btn btn-sm btn-info position-absolute input-button-right"
+              @click="applyManualCoordinates"
+              style="top: 0; right: 0;"
+              v-if="coordinatesInputIsValid"
+            >
+              <font-awesome-icon icon="check" />
+            </button>
+          </div>
+          <small class="d-flex text-left mt-2" v-if="startPoint && endPoint">
+            <div class="text-nowrap mr-3">
+              <b> <translate>Start</translate>: </b> <br />
+              Lat: {{ startPoint[1] }} <br />
+              Lon: {{ startPoint[0] }}
+            </div>
+            <div class="text-nowrap">
+              <b>End:</b> <br />
+              Lat: {{ endPoint[1] }} <br />
+              Lon: {{ endPoint[0] }}
+            </div>
+            <button
+              v-clipboard:copy="coordinatesForClipboard"
+              v-clipboard:success="onCopyCoordinates"
+              class="btn btn-info btn-sm ml-auto mt-auto"
+            >
+              <font-awesome-icon icon="copy" />
+            </button>
+          </small>
+          <div class="d-flex mt-3">
+            <div
+              class="pr-3 w-50"
+              v-if="startPoint && endPoint && !selectedCut"
+            >
+              <button
+                class="btn btn-info btn-sm w-100"
+                @click="showLabelInput = !showLabelInput"
+              >
+                <font-awesome-icon :icon="showLabelInput ? 'times' : 'check'" />
+                {{ showLabelInput ? "Cancel" : "Save" }}
+              </button>
+            </div>
+            <div
+              :class="startPoint && endPoint && !selectedCut ? 'w-50' : 'w-100'"
+            >
+              <button
+                class="btn btn-info btn-sm w-100"
+                @click="toggleCutTool"
+                :disabled="!selectedSurvey"
+              >
+                <font-awesome-icon :icon="cutToolEnabled ? 'times' : 'plus'" />
+                {{ cutToolEnabled ? "Cancel" : "New" }}
+              </button>
+            </div>
+          </div>
+          <div v-if="showLabelInput" class="mt-2">
+            <small class="text-muted">
+              <translate>Enter label for cross profile</translate>:
+            </small>
+            <div class="position-relative">
+              <input
+                class="form-control form-control-sm pr-5"
+                v-model="cutLabel"
+              />
+              <button
+                class="btn btn-sm btn-info position-absolute input-button-right"
+                @click="saveCut"
+                v-if="cutLabel"
+                style="top: 0; right: 0;"
+              >
+                <font-awesome-icon icon="check" />
+              </button>
+            </div>
+          </div>
+        </div>
+      </div>
+    </div>
+  </div>
+</template>
+
+<style lang="scss" scoped>
+.input-button-right {
+  border-top-right-radius: $border-radius;
+  border-bottom-right-radius: $border-radius;
+  border-top-left-radius: 0 !important;
+  border-bottom-left-radius: 0 !important;
+}
+
+.rounded-left-only {
+  border-top-right-radius: 0 !important;
+  border-bottom-right-radius: 0 !important;
+  border-top-left-radius: $border-radius;
+  border-bottom-left-radius: $border-radius;
+}
+
+input,
+select {
+  font-size: 0.8em;
+}
+</style>
+
+<script>
+/* This is Free Software under GNU Affero General Public License v >= 3.0
+ * without warranty, see README.md and license for details.
+ *
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ * License-Filename: LICENSES/AGPL-3.0.txt
+ *
+ * Copyright (C) 2018 by via donau
+ *   – Österreichische Wasserstraßen-Gesellschaft mbH
+ * Software engineering by Intevation GmbH
+ *
+ * Author(s):
+ * Markus Kottländer <markus.kottlaender@intevation.de>
+ */
+import { mapState, mapGetters } from "vuex";
+import Feature from "ol/Feature";
+import LineString from "ol/geom/LineString";
+import { displayError, displayInfo } from "@/lib/errors";
+import { HTTP } from "@/lib/http";
+import { COMPARESURVEYS } from "@/components/paneSetups";
+
+export default {
+  name: "profiles",
+  data() {
+    return {
+      coordinatesInput: "",
+      cutLabel: "",
+      showLabelInput: false
+    };
+  },
+  computed: {
+    ...mapState("application", ["showProfiles", "paneSetup"]),
+    ...mapState("map", ["openLayersMaps", "syncedMaps", "cutToolEnabled"]),
+    ...mapState("bottlenecks", [
+      "bottlenecksList",
+      "surveys",
+      "surveysLoading"
+    ]),
+    ...mapState("fairwayprofile", [
+      "previousCuts",
+      "startPoint",
+      "endPoint",
+      "profileLoading",
+      "differencesLoading",
+      "waterLevels",
+      "currentProfile"
+    ]),
+    ...mapGetters("map", ["openLayersMap"]),
+    ...mapGetters("bottlenecks", ["orderedBottlenecks"]),
+    profilesLable() {
+      return this.$gettext("Bottleneck Surveys");
+    },
+    selectedBottleneck: {
+      get() {
+        return this.$store.state.bottlenecks.selectedBottleneck;
+      },
+      set(name) {
+        this.$store.dispatch("bottlenecks/setSelectedBottleneck", name);
+      }
+    },
+    selectedWaterLevel: {
+      get() {
+        return this.$store.state.fairwayprofile.selectedWaterLevel;
+      },
+      set(value) {
+        this.$store.commit("fairwayprofile/setSelectedWaterLevel", value);
+      }
+    },
+    selectedSurvey: {
+      get() {
+        return this.$store.state.bottlenecks.selectedSurvey;
+      },
+      set(survey) {
+        this.$store.commit("fairwayprofile/additionalSurvey", null);
+        this.$store.commit("bottlenecks/selectedSurvey", survey);
+      }
+    },
+    additionalSurvey: {
+      get() {
+        return this.$store.state.fairwayprofile.additionalSurvey;
+      },
+      set(survey) {
+        this.$store.commit("fairwayprofile/additionalSurvey", survey);
+      }
+    },
+    selectedCut: {
+      get() {
+        return this.$store.state.fairwayprofile.selectedCut;
+      },
+      set(cut) {
+        this.$store.commit("fairwayprofile/selectedCut", cut);
+        if (!cut) {
+          this.$store.commit("fairwayprofile/clearCurrentProfile");
+          this.openLayersMaps.forEach(m => {
+            m.getLayer("CUTTOOL")
+              .getSource()
+              .clear();
+          });
+        }
+      }
+    },
+    additionalSurveys() {
+      return this.surveys.filter(
+        survey => survey.date_info !== this.selectedSurvey.date_info
+      );
+    },
+    coordinatesForClipboard() {
+      return (
+        this.startPoint[1] +
+        "," +
+        this.startPoint[0] +
+        "," +
+        this.endPoint[1] +
+        "," +
+        this.endPoint[0]
+      );
+    },
+    coordinatesInputIsValid() {
+      const coordinates = this.coordinatesInput
+        .split(",")
+        .map(coord => parseFloat(coord.trim()))
+        .filter(c => Number(c) === c);
+      return coordinates.length === 4;
+    },
+    differencesVisible() {
+      return (
+        this.openLayersMap(COMPARESURVEYS.compare.id) &&
+        !this.openLayersMap(COMPARESURVEYS.compare.id)
+          .getLayer("BOTTLENECKISOLINE")
+          .getVisible() &&
+        this.openLayersMap(COMPARESURVEYS.compare.id)
+          .getLayer("DIFFERENCES")
+          .getVisible()
+      );
+    },
+    rotatePanesTooltip() {
+      return this.$gettext("Rotate Maps");
+    },
+    syncMapsTooltip() {
+      return this.$gettext(
+        this.mapsAreSynced ? "Unsynchronize Maps" : "Synchronize Maps"
+      );
+    },
+    mapsAreSynced() {
+      return this.syncedMaps.includes(COMPARESURVEYS.compare.id);
+    },
+    bottleneck() {
+      return this.openLayersMap()
+        ? this.openLayersMap()
+            .getLayer("BOTTLENECKS")
+            .getSource()
+            .getFeatures()
+            .find(f => f.get("objnam") === this.selectedBottleneck)
+        : null;
+    }
+  },
+  watch: {
+    selectedBottleneck() {
+      this.$store.dispatch("fairwayprofile/previousCuts");
+      this.cutLabel =
+        this.selectedBottleneck + " (" + new Date().toISOString() + ")";
+    },
+    selectedSurvey(survey) {
+      this.loadProfile(survey);
+    },
+    additionalSurvey(survey) {
+      if (survey) {
+        this.loadDifferences();
+        this.$store.commit(
+          "application/paneSetup",
+          Object.keys(this.currentProfile).length
+            ? "COMPARESURVEYS_FAIRWAYPROFILE"
+            : "COMPARESURVEYS"
+        );
+        this.$store.commit("map/syncedMaps", [COMPARESURVEYS.compare.id]);
+      } else {
+        this.$store.commit(
+          "application/paneSetup",
+          Object.keys(this.currentProfile).length ? "FAIRWAYPROFILE" : "DEFAULT"
+        );
+        this.$store.commit("map/syncedMaps", []);
+      }
+      this.loadProfile(survey);
+    },
+    selectedCut(cut) {
+      if (cut) {
+        this.applyCoordinates(cut.coordinates);
+      }
+    }
+  },
+  methods: {
+    toggleSyncMaps() {
+      if (this.mapsAreSynced) {
+        this.$store.commit(
+          "map/syncedMaps",
+          this.syncedMaps.filter(m => m !== COMPARESURVEYS.compare.id)
+        );
+      } else {
+        this.$store.commit("map/syncedMaps", [COMPARESURVEYS.compare.id]);
+      }
+    },
+    loadDifferences() {
+      this.$store.commit("fairwayprofile/setDifferencesLoading", true);
+      HTTP.post(
+        "/diff",
+        {
+          bottleneck: this.selectedSurvey.bottleneck_id,
+          minuend: this.selectedSurvey.date_info,
+          subtrahend: this.additionalSurvey.date_info
+        },
+        {
+          headers: {
+            "X-Gemma-Auth": localStorage.getItem("token")
+          }
+        }
+      )
+        .then()
+        .catch(error => {
+          let status, data, message;
+          if (error.response) {
+            status = error.response.status;
+            data = error.response.data;
+            message = `${status}: ${data.message || data}`;
+          } else {
+            message = error;
+          }
+          displayError({
+            title: this.$gettext("Backend Error"),
+            message: message
+          });
+        })
+        .finally(() => {
+          this.$store.commit("fairwayprofile/setDifferencesLoading", false);
+        });
+    },
+    showDifferences() {
+      this.openLayersMap(COMPARESURVEYS.compare.id)
+        .getLayer("BOTTLENECKISOLINE")
+        .setVisible(false);
+      this.openLayersMap(COMPARESURVEYS.compare.id)
+        .getLayer("DIFFERENCES")
+        .setVisible(true);
+    },
+    showSurvey() {
+      this.openLayersMap(COMPARESURVEYS.compare.id)
+        .getLayer("BOTTLENECKISOLINE")
+        .setVisible(true);
+      this.openLayersMap(COMPARESURVEYS.compare.id)
+        .getLayer("DIFFERENCES")
+        .setVisible(false);
+    },
+    close() {
+      this.$store.commit("application/showProfiles", false);
+    },
+    loadProfile(survey) {
+      if (survey) {
+        this.$store.commit("fairwayprofile/profileLoading", true);
+        this.$store
+          .dispatch("fairwayprofile/loadProfile", survey)
+          .finally(() => {
+            this.$store.commit("fairwayprofile/profileLoading", false);
+          });
+      }
+    },
+    toggleCutTool() {
+      this.$store.commit("map/cutToolEnabled", !this.cutToolEnabled);
+      this.$store.commit("map/lineToolEnabled", false);
+      this.$store.commit("map/polygonToolEnabled", false);
+      this.$store.commit("map/setCurrentMeasurement", null);
+    },
+    onCopyCoordinates() {
+      displayInfo({
+        message: this.$gettext("Coordinates copied to clipboard!")
+      });
+    },
+    applyManualCoordinates() {
+      const coordinates = this.coordinatesInput
+        .split(",")
+        .map(coord => parseFloat(coord.trim()));
+      this.selectedCut = null;
+      this.coordinatesInput = "";
+      this.applyCoordinates([
+        coordinates[1],
+        coordinates[0],
+        coordinates[3],
+        coordinates[2]
+      ]);
+    },
+    applyCoordinates(coordinates) {
+      // allow only numbers
+      coordinates = coordinates.filter(c => Number(c) === c);
+      if (coordinates.length === 4) {
+        // draw line on map
+        this.openLayersMaps.forEach(m => {
+          m.getLayer("CUTTOOL")
+            .getSource()
+            .clear();
+        });
+        const cut = new Feature({
+          geometry: new LineString([
+            [coordinates[0], coordinates[1]],
+            [coordinates[2], coordinates[3]]
+          ]).transform("EPSG:4326", "EPSG:3857")
+        });
+        this.openLayersMaps.forEach(m => {
+          m.getLayer("CUTTOOL")
+            .getSource()
+            .addFeature(cut);
+        });
+
+        // draw diagram
+        this.$store.dispatch("fairwayprofile/cut", cut);
+      } else {
+        displayError({
+          title: this.$gettext("Invalid input"),
+          message: this.$gettext(
+            "Please enter correct coordinates in the format: Lat,Lon,Lat,Lon"
+          )
+        });
+      }
+    },
+    saveCut() {
+      const previousCuts =
+        JSON.parse(localStorage.getItem("previousCuts")) || [];
+      const newEntry = {
+        label: this.cutLabel,
+        bottleneckName: this.selectedBottleneck,
+        coordinates: [...this.startPoint, ...this.endPoint],
+        timestamp: new Date().getTime()
+      };
+      const existingEntry = previousCuts.find(cut => {
+        return JSON.stringify(cut) === JSON.stringify(newEntry);
+      });
+      if (!existingEntry) previousCuts.push(newEntry);
+      if (previousCuts.length > 100) previousCuts.shift();
+      localStorage.setItem("previousCuts", JSON.stringify(previousCuts));
+      this.$store.dispatch("fairwayprofile/previousCuts");
+
+      this.showLabelInput = false;
+      displayInfo({
+        title: this.$gettext("Profile saved!"),
+        message: this.$gettext(
+          'You can now select these coordinates from the "Saved cross profiles" menu to restore this cross profile.'
+        )
+      });
+    },
+    deleteSelectedSurvey() {
+      this.$store.commit("application/popup", {
+        icon: "trash",
+        title: this.$gettext("Delete survey"),
+        content:
+          this.$gettext("Do you really want to delete the survey:") +
+          `<br>
+        <b>${this.selectedBottleneck}: ${this.selectedSurvey.date_info}</b>`,
+        confirm: {
+          label: this.$gettext("Delete"),
+          icon: "trash",
+          callback: () => {
+            console.log("delete selected");
+            displayInfo({ title: this.$gettext("Profile deleted!") });
+          }
+        },
+        cancel: {
+          label: this.$gettext("Cancel"),
+          icon: "times"
+        }
+      });
+    },
+    deleteSelectedCut(cut) {
+      this.$store.commit("application/popup", {
+        icon: "trash",
+        title: this.$gettext("Delete cross profile"),
+        content:
+          this.$gettext("Do you really want to delete the cross profile:") +
+          `<br>
+        <b>${cut.label}</b>`,
+        confirm: {
+          label: this.$gettext("Delete"),
+          icon: "trash",
+          callback: () => {
+            let previousCuts =
+              JSON.parse(localStorage.getItem("previousCuts")) || [];
+            previousCuts = previousCuts.filter(cut => {
+              return JSON.stringify(cut) !== JSON.stringify(this.selectedCut);
+            });
+            localStorage.setItem("previousCuts", JSON.stringify(previousCuts));
+            this.$store.commit("fairwayprofile/selectedCut", null);
+            this.$store.dispatch("fairwayprofile/previousCuts");
+            displayInfo({ title: this.$gettext("Profile deleted!") });
+          }
+        },
+        cancel: {
+          label: this.$gettext("Cancel"),
+          icon: "times"
+        }
+      });
+    },
+    moveToBottleneck() {
+      const bottleneck = this.bottlenecksList.find(
+        bn => bn.properties.name === this.selectedBottleneck
+      );
+      if (!bottleneck) return;
+      this.$store.dispatch("map/moveToFeauture", {
+        feature: bottleneck,
+        zoom: 17,
+        preventZoomOut: true
+      });
+    }
+  },
+  mounted() {
+    this.$store.dispatch("bottlenecks/loadBottlenecksList");
+  }
+};
+</script>