view client/src/components/identify/Identify.vue @ 5667:57af2b37a37e clickable-links

Make reference gauge clickable
author Thomas Junk <thomas.junk@intevation.de>
date Tue, 05 Dec 2023 17:29:07 +0100
parents 77b6d1002e73
children
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 @click="selectGauge(prop.val)" href="#">{{ 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">
          <font-awesome-icon
            v-if="loadingDQL"
            icon="spinner"
            :spin="true"
            fixed-width
          />
          <template v-if="DQLDownloadAllowed">
            <a
              v-for="(reportName, index) in availableReports"
              :key="index"
              href="#"
              @click="downloadDataQualityReport(reportName)"
            >
              {{
                reportName
                  .split("-")
                  .map(s => (s && s[0].toUpperCase() + s.slice(1)) || "")
                  .join(" ")
              }}
            </a>
          </template>
          <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 scoped>
.features {
  max-height: 19rem;
  overflow-y: auto;
}

.features small {
}
.features small .zoom-btn {
  margin-top: -0.25rem;
  margin-right: -0.5rem;
  margin-bottom: -0.25rem;
}
.features small .zoom-btn svg {
  vertical-align: middle;
}
.features small:nth-child(even) {
  background: #f8f8f8;
}
.features small: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";
import { displayError } from "@/lib/errors";

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

export default {
  name: "identify",
  data() {
    return {
      loadingDQL: false,
      refGaugeStatus: "",
      gaugeStatus: "",
      gaugeCoeffs: null,
      refGaugeCoeffs: null
    };
  },
  computed: {
    ...mapGetters("application", ["versionStr"]),
    ...mapState("application", ["showIdentify", "userManualUrl", "config"]),
    ...mapGetters("map", ["filteredIdentifiedFeatures"]),
    ...mapState("map", ["currentMeasurement"]),
    ...mapGetters("map", ["openLayersMap"]),
    ...mapState("gauges", ["gauges"]),
    ...mapGetters("user", ["isWaterwayAdmin", "isSysAdmin"]),
    ...mapState("importschedule", ["availableReports"]),
    gaugesLookUp: {
      get() {
        return this.gauges.reduce((o, gauge) => {
          o[gauge.properties.objname] = gauge;
          return o;
        }, {});
      }
    },
    DQLDownloadAllowed() {
      if (this.loadingDQL) return false;
      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: {
    selectGauge(gauge_name) {
      const gauge = this.gaugesLookUp[gauge_name];
      const zoom = 16;
      this.openLayersMap()
        .getLayer("GAUGES")
        .setVisible(true);
      this.$store.dispatch(
        "gauges/setSelectedGaugeISRS",
        gauge.properties.isrs_code
      );
      this.$store.dispatch("map/moveMap", {
        coordinates: gauge.geometry.coordinates,
        zoom,
        preventZoomOut: true
      });
    },
    downloadDataQualityReport(reportName) {
      this.loadingDQL = true;
      HTTP.get(`/data/report/${reportName}`, {
        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);
        })
        .catch(error => {
          let message = "Backend not reachable";
          if (error.response) {
            const { status, data } = error.response;
            message = `${status}: ${data.message || data}`;
          }
          displayError({
            title: this.$gettext("Backend Error"),
            message: message
          });
        })
        .finally(() => {
          this.loadingDQL = false;
        });
    },
    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>