view client/src/components/fairway/Profiles.vue @ 3044:c71373594719

client: map: prepared store to hold multiple map objects This will be necessary to sync maps, toggle layers per map, etc. Therefore the methods to move the map (moveToExtent, etc.) became actions instead of mutations.
author Markus Kottlaender <markus@intevation.de>
date Sat, 13 Apr 2019 16:02:06 +0200
parents 44493664d40e
children 1ef2f4179d30
line wrap: on
line source

<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 || differencesLoading"
        />
        <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.name"
              :value="bn.properties.name"
            >
              {{ bn.properties.name }}
            </option>
          </optgroup>
        </select>
        <div v-if="selectedBottleneck">
          <div class="d-flex 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="" v-if="Object.keys(waterLevels).length === 0">
                  <translate>Current</translate>
                </option>
                <option
                  v-for="wl in Object.keys(waterLevels)"
                  :key="wl"
                  :value="wl"
                >
                  {{ wl | surveyDate }}
                </option>
              </select>
            </div>
            <div class="flex-fill ml-2">
              <small class="text-muted"> <translate>Survey</translate>: </small>
              <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>
            </div>
            <div
              class="flex-fill ml-2"
              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-3">
            <button
              :disabled="!additionalSurvey"
              class="btn btn-info btn-sm w-100"
              @click="showSurveyDiffences"
            >
              <translate>Show differences</translate>
            </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-sm btn-dark input-button-right"
              @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">
                <font-awesome-icon
                  :icon="cutTool && cutTool.getActive() ? 'times' : 'plus'"
                />
                {{ cutTool && cutTool.getActive() ? "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;
}
</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";

export default {
  name: "profiles",
  data() {
    return {
      coordinatesInput: "",
      cutLabel: "",
      showLabelInput: false
    };
  },
  computed: {
    ...mapState("application", ["showProfiles"]),
    ...mapState("map", ["lineTool", "polygonTool", "cutTool"]),
    ...mapState("bottlenecks", [
      "bottlenecksList",
      "surveys",
      "surveysLoading"
    ]),
    ...mapState("fairwayprofile", [
      "previousCuts",
      "startPoint",
      "endPoint",
      "profileLoading",
      "differencesLoading",
      "waterLevels"
    ]),
    ...mapGetters("map", ["openLayersMap"]),
    orderedBottlenecks() {
      let groupedBottlenecks = {},
        orderedGroups = {};

      // group bottlenecks by cc
      this.bottlenecksList.forEach(bn => {
        let cc = bn.properties.responsible_country;
        if (groupedBottlenecks.hasOwnProperty(cc)) {
          groupedBottlenecks[cc].push(bn);
        } else {
          groupedBottlenecks[cc] = [bn];
        }
      });

      // order groups by cc
      Object.keys(groupedBottlenecks)
        .sort()
        .forEach(cc => (orderedGroups[cc] = groupedBottlenecks[cc]));

      return orderedGroups;
    },
    profilesLable() {
      return this.$gettext("Profiles");
    },
    selectedBottleneck: {
      get() {
        return this.$store.state.bottlenecks.selectedBottleneck;
      },
      set(name) {
        this.$store
          .dispatch("bottlenecks/setSelectedBottleneck", name)
          .then(() => {
            this.$store.commit("bottlenecks/setFirstSurveySelected");
          });
      }
    },
    selectedWaterLevel: {
      get() {
        return this.$store.state.fairwayprofile.selectedWaterLevel.date || "";
      },
      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.$store.commit("application/showSplitscreen", false);
          this.openLayersMap
            .getLayer("CUTTOOL")
            .getSource()
            .clear();
        }
      }
    },
    additionalSurveys() {
      return this.surveys.filter(survey => survey !== this.selectedSurvey);
    },
    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;
    }
  },
  watch: {
    selectedBottleneck() {
      this.$store.dispatch("fairwayprofile/previousCuts");
      this.cutLabel =
        this.selectedBottleneck + " (" + new Date().toISOString() + ")";
    },
    selectedSurvey(survey) {
      this.loadProfile(survey);
    },
    additionalSurvey(survey) {
      this.loadProfile(survey);
    },
    selectedCut(cut) {
      if (cut) {
        this.applyCoordinates(cut.coordinates);
      }
    }
  },
  methods: {
    showSurveyDiffences() {
      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(() => {
          this.openLayersMap
            .getLayer("DIFFERENCES")
            .getSource()
            .updateParams({
              cql_filter:
                "objnam='" +
                this.selectedBottleneck +
                "' AND " +
                "minuend='" +
                this.selectedSurvey.date_info +
                "' AND subtrahend='" +
                this.additionalSurvey.date_info +
                "'"
            });
          this.openLayersMap.getLayer("BOTTLENECKISOLINE").setVisible(false);
          this.openLayersMap.getLayer("DIFFERENCES").setVisible(true);
        })
        .catch(error => {
          const { status, data } = error.response;
          displayError({
            title: this.$gettext("Backend Error"),
            message: `${status}: ${data.message || data}`
          });
        })
        .finally(() => {
          this.$store.commit("fairwayprofile/setDifferencesLoading", false);
        });
    },
    close() {
      this.$store.commit("application/showProfiles", false);
    },
    loadProfile(survey) {
      if (survey) {
        this.$store.commit("fairwayprofile/profileLoading", true);
        this.$store.commit("application/splitscreenLoading", true);
        this.$store
          .dispatch("fairwayprofile/loadProfile", survey)
          .finally(() => {
            this.$store.commit("fairwayprofile/profileLoading", false);
            this.$store.commit("application/splitscreenLoading", false);
          });
      }
    },
    toggleCutTool() {
      this.cutTool.setActive(!this.cutTool.getActive());
      this.lineTool.setActive(false);
      this.polygonTool.setActive(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.openLayersMap
          .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.openLayersMap
          .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.'
        )
      });
    },
    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/moveToExtent", {
        feature: bottleneck,
        zoom: 17,
        preventZoomOut: true
      });
    }
  },
  mounted() {
    this.$store.dispatch("bottlenecks/loadBottlenecksList");
  }
};
</script>