view client/src/components/identify/Identify.vue @ 5339:7365efe9f67d extented-report

Added downloadlink for DQLReports in Infotool In order to download reports on dataquality a link was added to the downloads section in the infotool. In case there are downloads available (e.g user manual is available) this section becomes visible. The old behavior for the single link is now extended for the whole section. The link for the DQLReports is only available for the roles of - Waterway Administrator - System Administrator
author Thomas Junk <thomas.junk@intevation.de>
date Thu, 17 Jun 2021 14:36:01 +0200
parents 96a544504818
children 2578fd6f4c63
line wrap: on
line source

<template>
  <div
    :class="[
      'box ui-element rounded bg-white text-nowrap',
      { expanded: showIdentify }
    ]"
  >
    <div style="width: 20rem">
      <UIBoxHeader
        icon="info"
        :title="identifiedLabel"
        :closeCallback="close"
      />
      <div class="features">
        <div v-if="currentMeasurement">
          <small class="d-block bg-secondary text-light px-2 py-1">
            <translate> Measurement</translate>
          </small>
          <small class="d-flex justify-content-between px-2">
            <b>
              {{ currentMeasurement.quantity }}
            </b>
            {{ currentMeasurement.value }} {{ currentMeasurement.unitSymbol }}
          </small>
        </div>
        <div
          v-for="feature of filteredIdentifiedFeatures"
          :key="feature.getId()"
        >
          <small
            class="d-flex justify-content-between bg-secondary text-light px-2 py-1"
          >
            {{ featureLabel(feature) }}
            <a
              v-if="feature.getProperties().hasOwnProperty('bbox')"
              @click="zoomTo(feature)"
              class="btn btn-info btn-xs pointer rounded-0 zoom-btn"
            >
              <font-awesome-icon icon="crosshairs" />
            </a>
          </small>
          <div
            class="text-left mt-2"
            v-if="showBottleneckMeta(feature) || showGaugeMeta(feature)"
          >
            <small class="ml-2 text-muted bg-white">
              Meta:
            </small>
            <hr style="margin-top:0.25em;margin-bottom:0.5em;" />
          </div>
          <div
            v-if="showBottleneckMeta(feature)"
            class="ml-1 mb-1 text-left d-flex flex-column"
          >
            <div class="d-flex">
              <font-awesome-icon
                icon="caret-up"
                fixed-width
                :style="{
                  color: recencyColor(feature),
                  'font-size': 'x-large'
                }"
              />
              <div class="d-flex flex-column">
                <small
                  v-for="(line, index) in recency(feature)"
                  :key="index"
                  class="bg-white my-auto text-wrap"
                >
                  {{ line }}
                </small>
              </div>
            </div>
            <div>
              <small><translate>According gauge data:</translate></small>
            </div>
          </div>
          <div
            v-if="showGaugeMeta(feature)"
            class="ml-1 mb-1 text-left d-flex flex-column"
          >
            <div class="d-flex">
              <div class="d-flex flex-column">
                <font-awesome-icon
                  icon="caret-up"
                  fixed-width
                  :style="{
                    color: gmAvailabilityColor(feature),
                    'font-size': 'x-large',
                    position: 'relative',
                    top: '0.28em'
                  }"
                />
                <font-awesome-icon
                  icon="caret-down"
                  fixed-width
                  :style="{
                    color: gmAvailabilityColor(feature),
                    'font-size': 'x-large',
                    position: 'relative',
                    top: '-0.45em'
                  }"
                />
              </div>
              <div class="d-flex flex-column">
                <small
                  v-for="(line, index) in gmAvailability(feature)"
                  class="bg-white my-auto"
                  :key="index"
                >
                  {{ line }}
                </small>
              </div>
            </div>
            <div class="mt-2 d-flex">
              <font-awesome-icon
                icon="caret-up"
                fixed-width
                :style="{
                  color: getGaugeStatusColor(feature),
                  'font-size': 'x-large'
                }"
              />
              <div class="d-flex flex-column">
                <small
                  v-for="(line, index) in getGaugeStatusText(feature)"
                  :key="index"
                  class="bg-white my-auto"
                >
                  {{ line }}
                </small>
              </div>
            </div>
            <div class="mt-2 d-flex">
              <font-awesome-icon
                icon="caret-up"
                fixed-width
                :style="{
                  color: forecastAccuracyColor(feature),
                  'font-size': 'x-large'
                }"
              />
              <div style="line-height:1.1em" class="d-flex flex-column">
                <small
                  v-for="(line, index) in forecastAccuracy(feature)"
                  :key="index"
                  class="bg-white my-auto"
                >
                  {{ line }}
                </small>
              </div>
            </div>
          </div>
          <hr
            v-if="showBottleneckMeta(feature) || showGaugeMeta(feature)"
            style="margin-top:0.5em;margin-bottom:0.25em;"
          />
          <div>
            <small
              v-for="prop in featureProps(feature)"
              :key="prop.key"
              v-if="prop.val"
              class="d-flex justify-content-between px-2"
            >
              <template v-if="prop.key != 'gauge objname'">
                <b>{{ prop.key }}</b>
                <span>{{ prop.val }}</span>
              </template>
              <template v-else>
                <b><translate>Reference Gauge</translate></b>
                <a>{{ prop.val }}</a>
              </template>
            </small>
          </div>
        </div>
        <div
          v-if="!currentMeasurement && !filteredIdentifiedFeatures.length"
          class="text-muted small text-center my-auto py-3 px-2"
        >
          <translate>No features identified.</translate>
        </div>
      </div>
      <div
        v-if="hasDownloads"
        class="border-top text-left pl-2"
        style="font-size: 90%;"
      >
        <translate>Download</translate>
        <div class="d-flex flex-column">
          <a
            v-if="DQLDownloadAllowed"
            href="#"
            @click="downloadDataQualityReport"
          >
            <translate>Data quality report</translate>
          </a>
          <a
            v-if="userManualUrl"
            :href="userManualUrl ? userManualUrl : '#'"
            :download="usermanualFilename"
            ><translate> User Manual</translate></a
          >
        </div>
      </div>
      <div class="versioninfo border-top box-body">
        <span v-translate="{ license: 'AGPL-3.0-or-later' }">
          This app uses <i>gemma</i>, which is Free Software under <br />
          %{ license } without warranty, see docs for details.
        </span>
        <br />
        <a href="https://hg.intevation.de/gemma/file/tip">
          <translate>source-code</translate>
        </a>
        {{ versionStr }} <br />© via donau. &#x24D4; Intevation. <br />
        <span v-translate="{ name: 'OpenSteetMap' }"
          >Some data ©
          <a href="https://www.openstreetmap.org/copyright">%{ name }</a>
          contributors.
        </span>
        <br />
        <span v-translate="{ geoLicense: 'CC-BY-4.0' }">
          Uses
          <a href="https://download.geonames.org/export/dump/readme.txt"
            >GeoNames</a
          >
          under %{ geoLicense }.
        </span>
        <translate>Generated PDFs use font: </translate>
        <a href="http://libertine-fonts.org">LinBiolinum</a>
      </div>
    </div>
  </div>
</template>

<style lang="scss" scoped>
.features {
  max-height: 19rem;
  overflow-y: auto;
  small {
    .zoom-btn {
      margin-top: -0.25rem;
      margin-right: -0.5rem;
      margin-bottom: -0.25rem;
      svg {
        vertical-align: middle;
      }
    }
    &:nth-child(even) {
      background: #f8f8f8;
    }
    &:hover {
      background: #eeeeee;
    }
  }
}

.versioninfo {
  font-size: 60%;
  white-space: normal;
}
</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, 2019 by via donau
 *   – Österreichische Wasserstraßen-Gesellschaft mbH
 * Software engineering by Intevation GmbH
 *
 * Author(s):
 * Thomas Junk <thomas.junk@intevation.de>
 * Bernhard E. Reiter <bernhard.reiter@intevation.de>
 * Markus Kottländer <markus.kottlaender@intevation.de>
 */
import { mapState, mapGetters } from "vuex";
import { formatter } from "./formatter";
import { getCenter } from "ol/extent";
import classifications from "@/lib/classifications";
import { styleFactory } from "@/components/layers/styles";
import filters from "@/lib/filters";
import { HTTP } from "@/lib/http";
import { format } from "date-fns";

const {
  recencyColorCodes,
  gmAvailabilityColorCodes,
  forecastAccuracyColorCodes,
  forecastVsRealityColorCodes
} = styleFactory();

export default {
  name: "identify",
  data() {
    return {
      refGaugeStatus: "",
      gaugeStatus: "",
      gaugeCoeffs: null,
      refGaugeCoeffs: null
    };
  },
  computed: {
    ...mapGetters("application", ["versionStr"]),
    ...mapState("application", ["showIdentify", "userManualUrl", "config"]),
    ...mapGetters("map", ["filteredIdentifiedFeatures"]),
    ...mapState("map", ["currentMeasurement"]),
    ...mapState("gauges", ["gauges"]),
    ...mapGetters("user", ["isWaterwayAdmin", "isSysAdmin"]),
    DQLDownloadAllowed() {
      return this.isWaterwayAdmin || this.isSysAdmin;
    },
    identifiedLabel() {
      return this.$gettext("Identified Features");
    },
    hasDownloads() {
      return this.DQLDownloadAllowed || this.userManualUrl;
    },
    usermanualFilename() {
      return this.$gettext("User Manual");
    },
    gaugeStatusColor() {
      return forecastVsRealityColorCodes[this.gaugeStatus];
    },
    gaugeStatusText() {
      const nsc24 = Number(this.config.gm_forecast_vs_reality_nsc_24h).toFixed(
        2
      );
      const nsc72 = Number(this.config.gm_forecast_vs_reality_nsc_72h).toFixed(
        2
      );
      const coeffWarn = this.gaugeCoeffs
        ? Number(this.gaugeCoeffs[2].value).toFixed(2)
        : "";
      const coeffDanger = this.gaugeCoeffs
        ? Number(this.gaugeCoeffs[0].value).toFixed(2)
        : "";
      const messagesPerState = {
        OK: [
          this.$gettext("Nash-Sutcliffe") +
            `(${coeffDanger} >${nsc24} /24h ${coeffWarn} >${nsc72} / 72h)`
        ],
        WARNING: [
          this.$gettext("Nash-Sutcliffe") + ` (${coeffWarn} < ${nsc72} / 72h)`
        ],
        DANGER: [
          this.$gettext("Nash-Sutcliffe") + ` (${coeffDanger} < ${nsc24} / 72h)`
        ],
        NEUTRAL: [this.$gettext("Nash-Sutcliffe not available")]
      };
      return messagesPerState[this.gaugeStatus];
    },
    refGaugeStatusColor() {
      return forecastVsRealityColorCodes[this.refGaugeStatus];
    },
    refGaugeStatusText() {
      const nsc24 = Number(this.config.gm_forecast_vs_reality_nsc_24h).toFixed(
        2
      );
      const nsc72 = Number(this.config.gm_forecast_vs_reality_nsc_72h).toFixed(
        2
      );
      const coeffWarn = this.refGaugeCoeffs
        ? Number(this.refGaugeCoeffs[2].value).toFixed(2)
        : "";
      const coeffDanger = this.refGaugeCoeffs
        ? Number(this.refGaugeCoeffs[0].value).toFixed(2)
        : "";
      const messagesPerState = {
        OK: [
          this.$gettext("Nash-Sutcliffe") +
            `(${coeffDanger} >${nsc24} /24h ${coeffWarn} >${nsc72} / 72h)`
        ],
        WARNING: [
          this.$gettext("Nash-Sutcliffe") + ` (${coeffWarn} < ${nsc72} / 72h)`
        ],
        DANGER: [
          this.$gettext("Nash-Sutcliffe") + ` (${coeffDanger} < ${nsc24} / 72h)`
        ],
        NEUTRAL: [this.$gettext("Nash-Sutcliffe not available")]
      };
      return messagesPerState[this.refGaugeStatus];
    }
  },
  watch: {
    filteredIdentifiedFeatures() {
      const bottlecks = this.filteredIdentifiedFeatures.filter(f =>
        /bottleneck/.test(f.id_)
      );
      const gauges = this.filteredIdentifiedFeatures.filter(f =>
        /gauge/.test(f.id_)
      );
      if (gauges.length > 0) {
        const isrs = gauges[0].get("isrs_code");
        this.$store
          .dispatch("gauges/getNashSutcliffeForISRS", isrs)
          .then(response => {
            this.gaugeCoeffs = response.coeffs;
            this.gaugeStatus = classifications.calcForecastVsRealityForNSC(
              response
            );
          });
      }
      if (bottlecks.length > 0) {
        const gauge = this.gauges.find(
          g => g.properties.objname === bottlecks[0].get("gauge_objname")
        );
        const isrs = gauge.properties.isrs_code;
        this.$store
          .dispatch("gauges/getNashSutcliffeForISRS", isrs)
          .then(response => {
            this.refGaugeCoeffs = response.coeffs;
            this.refGaugeStatus = classifications.calcForecastVsRealityForNSC(
              response
            );
          });
      }
    }
  },
  methods: {
    downloadDataQualityReport() {
      HTTP.get(`/data/report/gauges`, {
        responseType: "blob",
        headers: {
          "X-Gemma-Auth": localStorage.getItem("token")
        }
      }).then(response => {
        const link = document.createElement("a");
        const now = new Date();
        link.href = window.URL.createObjectURL(new Blob([response.data]));
        link.download = `DataQualityReport-${format(now, "YYYY-MM-DD")}.xlsx`;
        document.body.appendChild(link);
        link.click();
        document.body.removeChild(link);
      });
    },
    getGaugeStatusText(feature) {
      if (/bottleneck/.test(feature.getId())) return this.refGaugeStatusText;
      return this.gaugeStatusText;
    },
    getGaugeStatusColor(feature) {
      if (/bottleneck/.test(feature.getId())) return this.refGaugeStatusColor;
      return this.gaugeStatusColor;
    },
    gmAvailability(feature) {
      const latestInHours = this.config.gm_latest_hours;
      const measurementsIn14D = this.config.gm_min_values_14d;
      const gauge = classifications.getGauge(feature);
      const lastMeasureDate = gauge.get("gm_measuredate")
        ? filters.surveyDate(new Date(gauge.get("gm_measuredate")))
        : this.$gettext("No measurement available");
      const in14Days = gauge.get("gm_n_14d");
      const messagesPerState = {
        OK: [
          this.$gettext("Avail: Latest measurement from ") +
            `${lastMeasureDate}`,
          this.$gettext("Measurement is within") + ` ${latestInHours}h`,
          `${in14Days} / ${measurementsIn14D} ${this.$gettext(
            "measurements"
          )} in 14d`
        ],
        WARNING: [
          this.$gettext("Avail: Below treshold"),
          `${in14Days} / ${measurementsIn14D} ${this.$gettext(
            "measurements"
          )} in 14d`
        ],
        DANGER: [
          this.$gettext("Avail: Latest measurement older than") +
            ` ${latestInHours}h`,
          `(${lastMeasureDate})`
        ]
      };
      return messagesPerState[classifications.gmAvailability(feature)];
    },
    gmAvailabilityColor(feature) {
      return gmAvailabilityColorCodes[classifications.gmAvailability(feature)];
    },
    forecastAccuracy(feature) {
      const offset24 = this.config.gm_forecast_offset_24h;
      const offset72 = this.config.gm_forecast_offset_72h;
      const fa3d = feature.get("forecast_accuracy_3d");
      const fa1d = feature.get("forecast_accuracy_1d");
      const messagesPerState = {
        OK: [
          this.$gettext("Highest confidence"),
          `${fa1d} < ${offset24} cm/24h`,
          `${fa3d} < ${offset72} cm/72h`
        ],
        WARNING: [
          this.$gettext("Confidence per 72h") + ` (${fa3d} cm > ${offset72} cm)`
        ],
        DANGER: [
          this.$gettext("Confidence per 24h") + ` (${fa1d} cm > ${offset24} cm)`
        ],
        NEUTRAL: [this.$gettext("Predictions not available")]
      };
      return messagesPerState[classifications.forecastAccuracy(feature)];
    },
    forecastAccuracyColor(feature) {
      return forecastAccuracyColorCodes[
        classifications.forecastAccuracy(feature)
      ];
    },
    recency(feature) {
      const revisitingFactor = this.config.bn_revtime_multiplier;
      const revisitingTime = feature.get("revisiting_time");
      if (!revisitingTime) return [this.$gettext("No revisiting time defined")];
      const latest = feature.get("date_max");
      if (!latest) return [this.$gettext("No survey-data available")];
      const latestMeasurement = filters.surveyDate(
        // remove a tailing "Z" if there is one, as some versions of
        // firefox barf on it. The definition of Date Time String Format
        // used for new Date() assumes UTC so it is okay, see
        // http://www.ecma-international.org/ecma-262/5.1/#sec-15.9.1.15
        new Date(latest.replace("Z", ""))
      );
      const messagesPerState = {
        OK: [
          this.$gettext("Data within the revisiting time") +
            ` (${revisitingTime} mth)`,
          `${this.$gettext("Latest measurement")} ${latestMeasurement}`
        ],
        WARNING: [
          this.$gettext("Data within revisiting treshold") +
            ` (${revisitingFactor} * ${revisitingTime})`,
          `${this.$gettext("Latest measurement")} ${latestMeasurement}`
        ],
        DANGER: [
          this.$gettext("Data too old. Treshold:") +
            ` (${revisitingFactor} * ${revisitingTime}mth)`,
          `${this.$gettext("Latest measurement")} ${latestMeasurement}`
        ]
      };
      return messagesPerState[classifications.surveyRecency(feature)];
    },
    recencyColor(feature) {
      return recencyColorCodes[classifications.surveyRecency(feature)];
    },
    showBottleneckMeta(feature) {
      const result = /bottleneck/.test(feature.getId().toLowerCase());
      return result;
    },
    showGaugeMeta(feature) {
      const result = /bottleneck|gauge/.test(feature.getId().toLowerCase());
      return result;
    },
    zoomTo(feature) {
      this.$store.dispatch("map/moveMap", {
        coordinates: getCenter(
          feature
            .getGeometry()
            .clone()
            .transform("EPSG:3857", "EPSG:4326")
            .getExtent()
        ),
        zoom: 17,
        preventZoomOut: true
      });
    },
    close() {
      this.$store.commit("application/showIdentify", false);
    },
    featureId(feature) {
      // cut away everything from the last . to the end
      let id = "";
      if (feature.getId) {
        id = feature.getId();
      }
      if (feature.id) {
        id = feature.id;
      }
      return id.replace(/[.][^.]*$/, "");
    },
    featureLabel(feature) {
      const featureID = this.featureId(feature);
      if (formatter.hasOwnProperty(this.featureId(feature))) {
        return formatter[featureID].label;
      }
      if (/fairway_marks/.test(featureID)) {
        return this.captionFairwayMarks(
          featureID.replace("fairway_marks_", "")
        );
      }
      return featureID;
    },
    captionFairwayMarks(id) {
      const captions = {
        bcnisd: this.$gettext(
          "Beacon, isolated danger (MARITIME/Hydro feature)"
        ),
        bcnlat_hydro: this.$gettext("Beacon, lateral (MARITIME/Hydro feature)"),
        bcnlat_ienc: this.$gettext("Beacon, lateral (IENC feature)"),
        boycar: this.$gettext("Buoy, cardinal (MARITIME/Hydro feature)"),
        boyisd: this.$gettext("Buoy, isolated danger (MARITIME/Hydro feature)"),
        boylat_hydro: this.$gettext("Buoy, lateral (MARITIME/Hydro feature)"),
        boylat_ienc: this.$gettext("Buoy, lateral (IENC feature)"),
        boysaw: this.$gettext("Buoy, safe water (MARITIME/Hydro feature)"),
        boyspp: this.$gettext(
          "Buoy, special purpose/general (MARITIME/Hydro feature)"
        ),
        daymar_hydro: this.$gettext("Daymark (MARITIME/Hydro feature)"),
        daymar_ienc: this.$gettext("Daymark (IENC feature)"),
        lights: this.$gettext("Light (MARITIME/Hydro feature)"),
        rtpbcn: this.$gettext(
          "Radar transponder beacon (MARITIME/Hydro feature)"
        ),
        topmar: this.$gettext("Topmark (MARITIME/Hydro feature)"),
        notmrk: this.$gettext("Notice mark (IENC feature)")
      };
      if (captions[id]) return captions[id];
      return id;
    },
    featureProps(feature) {
      let featureId = this.featureId(feature);

      // create array with {key, val} objects
      //   skip geometry here, because it is slightly more robust
      //   to get the name of the property to skip and we need a reference
      //   to `feature` for doing so.
      //   The geometry is not needed (and previous comments in the code
      //      mentioned a problem with it becoming cyclic when left in).
      let skipList = [feature.getGeometryName()];
      let propsArray = [];
      Object.keys(feature.getProperties()).forEach(key => {
        if (skipList.indexOf(key) === -1) {
          let val = feature.getProperties()[key];

          // if val is a valid json object string,
          // spread its values into the array
          let jsonObj = this.getObjectFromString(val);
          if (jsonObj) {
            Object.keys(jsonObj).forEach(key => {
              propsArray.push({ key, val: jsonObj[key] });
            });
          } else {
            // otherwise just put the key value pair into the array
            propsArray.push({ key, val });
          }
        }
      });

      // run general formatter
      propsArray = propsArray.map(formatter.all).filter(p => p);
      // run feature specific formatter
      if (
        formatter.hasOwnProperty(featureId) &&
        formatter[featureId].hasOwnProperty("props")
      ) {
        propsArray = propsArray.map(formatter[featureId].props).filter(p => p);
      }
      // remove underscores in labels that where not previously changed already
      propsArray = propsArray.map(prop => {
        return { key: prop.key.replace(/_/g, " "), val: prop.val };
      });

      return propsArray;
    },
    getObjectFromString(val) {
      // JSON.parse() accepts integers and null as valid json. So to be sure to
      // get an object, we cannot just try JSON.parse() but we need to check if
      // the given value is a string and starts with a {.
      if (
        Object.prototype.toString.call(val) === "[object String]" &&
        val[0] === "{"
      ) {
        try {
          return JSON.parse(val);
        } catch (e) {
          return null;
        }
      }
      return null;
    }
  }
};
</script>