changeset 5185:3c748b2b4de6 new-fwa

Merged default into new-fwa branch.
author Sascha L. Teichmann <sascha.teichmann@intevation.de>
date Fri, 27 Mar 2020 15:57:40 +0100
parents 445daeefca7b (current diff) 53618d18e387 (diff)
children 1c5c9fdaf730
files client/src/components/map/layers.js client/src/components/map/styles.js pkg/controllers/routes.go
diffstat 37 files changed, 2350 insertions(+), 1376 deletions(-) [+]
line wrap: on
line diff
--- a/client/src/components/App.vue	Mon Mar 23 15:29:55 2020 +0100
+++ b/client/src/components/App.vue	Fri Mar 27 15:57:40 2020 +0100
@@ -26,6 +26,7 @@
         </div>
       </div>
       <MapPopup />
+      <TimeSlider v-if="isMapVisible" />
     </div>
     <router-view />
     <vue-snotify />
@@ -111,6 +112,7 @@
     Layers: () => import("./layers/Layers"),
     Sidebar: () => import("./Sidebar"),
     Search: () => import("./Search"),
+    TimeSlider: () => import("./TimeSlider"),
     Contextbox: () => import("./Contextbox"),
     Toolbar: () => import("./toolbar/Toolbar"),
     Popup: () => import("./Popup"),
--- a/client/src/components/Search.vue	Mon Mar 23 15:29:55 2020 +0100
+++ b/client/src/components/Search.vue	Fri Mar 27 15:57:40 2020 +0100
@@ -188,7 +188,9 @@
     ...mapState("application", [
       "showSearchbar",
       "showContextBox",
-      "contextBoxContent"
+      "contextBoxContent",
+      "showTimeSlider",
+      "currentVisibleTime"
     ]),
     ...mapState("imports", ["startDate", "endDate"]),
     ...mapGetters("imports", ["filters"]),
@@ -242,6 +244,9 @@
     searchQuery: function() {
       this.searchQueryIsDirty = true;
       if (!this.showContextBox) this.triggerSearch();
+    },
+    currentVisibleTime() {
+      this.doSearch();
     }
   },
   methods: {
@@ -286,7 +291,12 @@
 
       HTTP.post(
         "/search",
-        { string: this.searchQuery },
+        this.showTimeSlider
+          ? {
+              string: this.searchQuery,
+              time: this.currentVisibleTime.toISOString()
+            }
+          : { string: this.searchQuery },
         {
           headers: {
             "X-Gemma-Auth": localStorage.getItem("token"),
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/client/src/components/TimeSlider.vue	Fri Mar 27 15:57:40 2020 +0100
@@ -0,0 +1,385 @@
+<template>
+  <div
+    id="slider"
+    :class="[
+      'd-flex box ui-element rounded bg-white flex-row',
+      { expanded: showTimeSlider },
+      {
+        reposition: ['DEFAULT', 'COMPARESURVEYS'].indexOf(this.paneSetup) === -1
+      }
+    ]"
+  >
+    <div id="timeselection" class="d-flex mt-1 mr-1">
+      <input
+        class="form-control-sm mr-1"
+        type="date"
+        v-model="dateSelection"
+        min="2015-01-01"
+        :max="new Date().toISOString().split('T')[0]"
+        required
+      />
+      <input
+        type="time"
+        min="00:00"
+        max="23:59"
+        v-model="timeSelection"
+        class="form-control-sm"
+        required
+      />
+    </div>
+    <div
+      id="sliderContainer"
+      class="d-flex sliderContainer"
+      style="width: 98%;"
+    ></div>
+    <div
+      id="closebutton"
+      @click="close"
+      class="d-flex box-control mr-0"
+      style="width: 2%;"
+    >
+      <font-awesome-icon icon="times"></font-awesome-icon>
+    </div>
+  </div>
+</template>
+<style lang="scss" scoped>
+#slider {
+  position: absolute;
+  bottom: 0;
+  min-width: 100vw;
+}
+// reposition time slider in case of opened diagram
+#slider.reposition {
+  bottom: 50%;
+}
+#slider.expanded {
+  max-height: 100%;
+  max-width: 100%;
+  margin: 0;
+}
+input::-webkit-clear-button {
+  display: none;
+}
+// hide clear button on IE
+input::-ms-clear {
+  display: none;
+}
+</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) 2020 by via donau
+ *   – Österreichische Wasserstraßen-Gesellschaft mbH
+ * Software engineering by Intevation GmbH
+ *
+ * Author(s):
+ * Fadi Abbud <fadiabbud@intevation.de>
+ */
+import { mapState } from "vuex";
+import * as d3 from "d3";
+import app from "@/main";
+import { localeDateString } from "@/lib/datelocalization";
+import { format, setHours, setMinutes, compareAsc } from "date-fns";
+import debounce from "debounce";
+
+let zoom = null;
+let xScale = null;
+let xAxis = null;
+let currentScaleFactor = 1;
+
+export default {
+  name: "timeslider",
+  data() {
+    return {
+      isSelectedTimeHourly: false,
+      resizeListenerFunction: null
+    };
+  },
+  watch: {
+    ongoingRefresh() {
+      if (this.ongoingRefresh) return;
+      this.$store.commit("application/setSelectedTime", new Date());
+      this.$nextTick(this.rescaleSlider(1));
+    },
+    ongoingTimeSlide() {
+      if (this.ongoingTimeSlide) return;
+      this.$store.commit(
+        "application/setCurrentVisibleTime",
+        this.refreshLayersTime
+      );
+    },
+    selectedTime() {
+      this.triggerMapReload();
+    },
+    sourcesLoading() {
+      // initiate refresh layers request if the time for the finished request
+      // differs from the selected time on time slider
+      if (this.sourcesLoading !== 0) return;
+      if (compareAsc(this.selectedTime, this.currentVisibleTime) === 0) return;
+      this.triggerMapReload();
+    }
+  },
+  computed: {
+    ...mapState("application", [
+      "showTimeSlider",
+      "paneSetup",
+      "currentVisibleTime",
+      "refreshLayersTime"
+    ]),
+    ...mapState("map", [
+      "ongoingRefresh",
+      "ongoingTimeSlide",
+      "openLayersMaps"
+    ]),
+    dateSelection: {
+      get() {
+        const date = this.$store.state.application.selectedTime;
+        return format(date, "YYYY-MM-DD");
+      },
+      set(value) {
+        if (!value) return;
+        let date = new Date(value);
+        const [hours, minutes] = this.timeSelection.split(":");
+        date = setHours(date, hours);
+        date = setMinutes(date, minutes);
+        this.$store.commit("application/setSelectedTime", date);
+        this.rescaleSlider(50);
+      }
+    },
+    timeSelection: {
+      get() {
+        const time = this.$store.state.application.selectedTime;
+        return format(time, "HH:mm");
+      },
+      set(value) {
+        if (!value) return;
+        let date = this.selectedTime;
+        date = setHours(date, value.split(":")[0]);
+        date = setMinutes(date, value.split(":")[1]);
+        this.$store.commit("application/setSelectedTime", date);
+        this.rescaleSlider(800);
+      }
+    },
+    selectedTime: {
+      get() {
+        return this.$store.state.application.selectedTime;
+      },
+      set(value) {
+        if (!this.isSelectedTimeHourly) {
+          value = setHours(value, 12);
+          value = setMinutes(value, 0);
+        }
+        this.$store.commit("application/setSelectedTime", value);
+      }
+    },
+    sourcesLoading() {
+      const layers = [
+        "BOTTLENECKS",
+        "GAUGES",
+        "FAIRWAYDIMENSIONSLOS1",
+        "FAIRWAYDIMENSIONSLOS2",
+        "FAIRWAYDIMENSIONSLOS3",
+        "WATERWAYAXIS",
+        "FAIRWAYMARKS"
+      ];
+      let counter = 0;
+      this.openLayersMaps.forEach(map => {
+        for (let i = 0; i < layers.length; i++) {
+          let layer = map.getLayer(layers[i]);
+          if (layer.getSource().loading) counter++;
+        }
+      });
+      return counter;
+    }
+  },
+  methods: {
+    close() {
+      this.$store.commit("application/showTimeSlider", false);
+      this.$store.commit("application/setStoredTime", this.currentVisibleTime);
+      this.$store.commit("application/setSelectedTime", new Date());
+    },
+    triggerMapReload() {
+      // trigger refresh layers only when last loading of layers was ended
+      if (this.sourcesLoading) {
+        return;
+      }
+      this.$store.commit(
+        "application/setLayerRefreshedTime",
+        this.selectedTime
+      );
+      this.$store.commit("map/startTimeSlide");
+      this.$store.dispatch("map/refreshTimebasedLayers");
+      this.$nextTick(() => {
+        this.$store.commit("map/finishTimeSlide");
+      });
+    },
+    rescaleSlider(scaleFactor) {
+      const tx =
+        -scaleFactor *
+          this.getScale()(d3.isoParse(this.selectedTime.toISOString())) +
+        document.getElementById("sliderContainer").clientWidth / 2;
+      var t = d3.zoomIdentity.translate(tx, 0).scale(scaleFactor);
+      this.getScale().domain(t.rescaleX(this.getScale()));
+      d3.select(".zoom").call(zoom.transform, t);
+    },
+    createSlider() {
+      const element = document.getElementById("sliderContainer");
+      const svgWidth = element ? element.clientWidth : 0,
+        svgHeight = 40,
+        marginTop = 20,
+        marginLeft = 0;
+
+      d3.timeFormatDefaultLocale(localeDateString);
+      xScale = this.getScale();
+      xAxis = this.getAxes();
+      let svg = d3
+        .select(".sliderContainer")
+        .append("svg")
+        .attr("width", svgWidth)
+        .attr("height", svgHeight);
+
+      zoom = d3
+        .zoom()
+        .scaleExtent([0.8, 102000])
+        .translateExtent([[0, 0], [svgWidth, svgHeight]])
+        .extent([[0, 0], [(svgWidth, svgHeight)]])
+        .on("zoom", this.zoomed);
+
+      svg
+        .append("g")
+        .attr("class", "axis--x")
+        .attr("transform", `translate(${marginLeft}, ${marginTop})`)
+        .call(xAxis);
+
+      // create rectanlge on the slider area to capture mouse events
+      const eventRect = svg
+        .append("rect")
+        .attr("id", "zoom")
+        .attr("class", "zoom")
+        .attr("width", svgWidth)
+        .attr("height", svgHeight)
+        .attr("fill", "white")
+        .attr("opacity", 0.2)
+        .on("mouseover", () => {
+          svg.select(".zoom").attr("cursor", "move");
+        });
+      eventRect.call(zoom).on("click", this.onClick);
+
+      const toIsoDate = d => {
+        return d.toISOString();
+      };
+
+      let drag = d3
+        .drag()
+        .on("start", () => {
+          d3.select(".line")
+            .raise()
+            .classed("active", true);
+        })
+        .on("drag", this.onDrag)
+        .on("end", () => {
+          d3.select(".line").classed("active", false);
+        });
+
+      // Create cursor to indicate to the selected time
+      svg
+        .append("rect")
+        .attr("class", "line")
+        .attr("id", "scrubber")
+        .attr("x", xAxis.scale()(d3.isoParse(toIsoDate(this.selectedTime))))
+        .attr("y", 0)
+        .attr("width", 2)
+        .attr("height", svgHeight)
+        .attr("stroke", "#17a2b8")
+        .attr("stroke-width", 2)
+        .attr("opacity", 0.6)
+        .on("mouseover", () => {
+          svg.select(".line").attr("cursor", "e-resize");
+        })
+        .call(drag);
+    },
+    getScale() {
+      return d3
+        .scaleTime()
+        .range([0, document.getElementById("sliderContainer").clientWidth || 0])
+        .domain([d3.isoParse(new Date("2015-01-01")), d3.isoParse(new Date())]);
+    },
+    getAxes() {
+      const axesFormat = date => {
+        return (d3.timeSecond(date) < date
+          ? d3.timeFormat(".%L")
+          : d3.timeMinute(date) < date
+          ? d3.timeFormat(":%S")
+          : d3.timeHour(date) < date
+          ? d3.timeFormat("%H:%M")
+          : d3.timeDay(date) < date
+          ? d3.timeFormat("%H:%M")
+          : d3.timeMonth(date) < date
+          ? d3.timeWeek(date) < date
+            ? d3.timeFormat(app.$gettext("%a %d"))
+            : d3.timeFormat(app.$gettext("%b %d"))
+          : d3.timeYear(date) < date
+          ? d3.timeFormat("%B")
+          : d3.timeFormat("%Y"))(date);
+      };
+      return d3
+        .axisBottom(xScale)
+        .ticks(12)
+        .tickFormat(axesFormat);
+    },
+    zoomed() {
+      let newX = d3.event.transform.rescaleX(xScale);
+      currentScaleFactor = d3.event.transform.k;
+      const isHourly = currentScaleFactor > 400;
+      if (this.isSelectedTimeHourly != isHourly)
+        this.isSelectedTimeHourly = isHourly;
+      xAxis.scale(newX);
+      d3.select(".axis--x").call(xAxis);
+      d3.select(".line").attr("x", newX(d3.isoParse(this.selectedTime)));
+    },
+    onClick() {
+      // Extract the click location
+      let point = d3.mouse(document.getElementById("zoom")),
+        p = { x: point[0], y: point[1] };
+      d3.select(".line").attr("x", p.x);
+      this.selectedTime = d3.isoParse(xAxis.scale().invert(p.x + 2));
+    },
+    onDrag() {
+      this.selectedTime = d3.isoParse(xAxis.scale().invert(d3.event.x + 2));
+      d3.select(".line").attr("x", d3.event.x);
+    },
+    redrawTimeSlider() {
+      const bodyWidth = document.querySelector("body").clientWidth;
+      const timeSelectionWidth = document.querySelector("#timeselection")
+        .clientWidth;
+      const closeButton = document.querySelector("#closebutton").clientWidth;
+      const svgWidth = bodyWidth - timeSelectionWidth - closeButton;
+      document
+        .querySelector(".sliderContainer svg")
+        .setAttribute("width", svgWidth);
+      xScale.range([0, svgWidth]);
+      xAxis.scale(xScale);
+      d3.select(".axis--x").call(xAxis);
+      d3.select(".line").attr(
+        "x",
+        xAxis.scale()(d3.isoParse(this.selectedTime))
+      );
+      this.rescaleSlider(currentScaleFactor);
+    }
+  },
+  created() {
+    this.resizeListenerFunction = debounce(this.redrawTimeSlider, 100);
+    window.addEventListener("resize", this.resizeListenerFunction);
+  },
+  destroyed() {
+    window.removeEventListener("resize", this.resizeListenerFunction);
+  },
+  mounted() {
+    setTimeout(this.createSlider, 150);
+  }
+};
+</script>
--- a/client/src/components/identify/Identify.vue	Mon Mar 23 15:29:55 2020 +0100
+++ b/client/src/components/identify/Identify.vue	Fri Mar 27 15:57:40 2020 +0100
@@ -270,7 +270,7 @@
 import { formatter } from "./formatter";
 import { getCenter } from "ol/extent";
 import classifications from "@/lib/classifications";
-import { styleFactory } from "@/components/map/styles";
+import { styleFactory } from "@/components/layers/styles";
 import filters from "@/lib/filters";
 
 const {
--- a/client/src/components/importoverview/FairwayDimensionDetail.vue	Mon Mar 23 15:29:55 2020 +0100
+++ b/client/src/components/importoverview/FairwayDimensionDetail.vue	Fri Mar 27 15:57:40 2020 +0100
@@ -30,7 +30,7 @@
 //import { displayError } from "@/lib/errors";
 import { mapGetters } from "vuex";
 import VectorSource from "ol/source/Vector";
-import { buildVectorLoader } from "@/components/map/layers.js";
+import { buildVectorLoader } from "@/components/layers/layers.js";
 import { bbox as bboxStrategy } from "ol/loadingstrategy";
 import { WFS } from "ol/format";
 import { HTTP } from "@/lib/http";
--- a/client/src/components/layers/Layers.vue	Mon Mar 23 15:29:55 2020 +0100
+++ b/client/src/components/layers/Layers.vue	Fri Mar 27 15:57:40 2020 +0100
@@ -99,6 +99,7 @@
       this.$store.commit("application/showLayers", false);
     },
     refreshLayers() {
+      this.$store.commit("application/setLayerRefreshedTime", new Date());
       this.$store.commit("map/startRefreshLayers");
       this.$store.commit("gauges/deleteNashSutcliffeCache");
       this.$store.dispatch("map/refreshLayers");
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/client/src/components/layers/layers.js	Fri Mar 27 15:57:40 2020 +0100
@@ -0,0 +1,724 @@
+import { GeoJSON, WFS } from "ol/format";
+import { Icon, Stroke, Style } from "ol/style";
+import {
+  Image as ImageLayer,
+  Tile as TileLayer,
+  Vector as VectorLayer
+} from "ol/layer";
+import { and as andFilter, equalTo } from "ol/format/filter";
+
+import { HTTP } from "@/lib/http";
+import { ImageWMS as ImageSource } from "ol/source";
+import OSM from "ol/source/OSM";
+import Point from "ol/geom/Point";
+import TileWMS from "ol/source/TileWMS";
+import VectorSource from "ol/source/Vector";
+import { bbox as bboxStrategy } from "ol/loadingstrategy";
+import store from "@/store/index";
+import { styleFactory } from "./styles";
+
+export const buildVectorLoader = (
+  featureRequestOptions,
+  vectorSource,
+  bboxStrategyDisabled,
+  featurePostProcessor
+) => {
+  // build a function to be used for VectorSource.setLoader()
+  // make use of WFS().writeGetFeature to build the request
+  // and use our HTTP library to actually do it
+  // NOTE: the geometryName has to be given in featureRequestOptions if
+  // bboxStrategy (default) is used
+  featureRequestOptions.featureNS = "gemma";
+  featureRequestOptions.featurePrefix = "gemma";
+  featureRequestOptions.outputFormat = "application/json";
+  return (extent, resolution, projection) => {
+    if (!bboxStrategyDisabled) {
+      featureRequestOptions.bbox = extent;
+    }
+    featureRequestOptions.srsName = projection.getCode();
+    HTTP.post(
+      "/internal/wfs",
+      new XMLSerializer().serializeToString(
+        new WFS().writeGetFeature(featureRequestOptions)
+      ),
+      {
+        headers: {
+          "X-Gemma-Auth": localStorage.getItem("token"),
+          "Content-type": "text/xml; charset=UTF-8"
+        }
+      }
+    )
+      .then(response => {
+        const features = new GeoJSON().readFeatures(
+          JSON.stringify(response.data)
+        );
+        if (featurePostProcessor) {
+          features.map(f => featurePostProcessor(f, store, features));
+        }
+        vectorSource.addFeatures(features);
+      })
+      .catch(() => {
+        vectorSource.removeLoadedExtent(extent);
+        store.dispatch("application/reportBackendError");
+      });
+  };
+};
+
+// SHARED LAYERS:
+// DRAW- and CUTLAYER are shared across maps. E.g. you want to see the cross cut
+// arrow on both maps when comparing surveys. So we don't need to initialize a
+// new VectorLayer object for each map. Instead we use these two constants so
+// that all maps use the same object.
+const DRAWLAYER = new VectorLayer({
+  id: "DRAWTOOL",
+  label: "Draw Tool",
+  visible: true,
+  source: new VectorSource({ wrapX: false }),
+  style: function(feature) {
+    // adapted from OpenLayer's LineString Arrow Example
+    var geometry = feature.getGeometry();
+    var styles = [
+      // linestring
+      new Style({
+        stroke: new Stroke({
+          color: "#369aca",
+          width: 2
+        })
+      })
+    ];
+
+    if (geometry.getType() === "LineString") {
+      geometry.forEachSegment(function(start, end) {
+        var dx = end[0] - start[0];
+        var dy = end[1] - start[1];
+        var rotation = Math.atan2(dy, dx);
+        // arrows
+        styles.push(
+          new Style({
+            geometry: new Point(end),
+            image: new Icon({
+              // we need to make sure the image is loaded by Vue Loader
+              src: require("@/assets/linestring_arrow.png"),
+              // fiddling with the anchor's y value does not help to
+              // position the image more centered on the line ending, as the
+              // default line style seems to be slightly uncentered in the
+              // anti-aliasing, but the image is not placed with subpixel
+              // precision
+              anchor: [0.75, 0.5],
+              rotateWithView: true,
+              rotation: -rotation
+            })
+          })
+        );
+      });
+    }
+    return styles;
+  }
+});
+
+const CUTLAYER = new VectorLayer({
+  id: "CUTTOOL",
+  label: "Cut Tool",
+  visible: true,
+  source: new VectorSource({ wrapX: false }),
+  style: function(feature) {
+    // adapted from OpenLayer's LineString Arrow Example
+    var geometry = feature.getGeometry();
+    var styles = [
+      // linestring
+      new Style({
+        stroke: new Stroke({
+          color: "#FFFFFF",
+          width: 5,
+          lineDash: [7, 7]
+        })
+      }),
+      new Style({
+        stroke: new Stroke({
+          color: "#333333",
+          width: 3,
+          lineDash: [7, 7]
+        })
+      })
+    ];
+
+    if (geometry.getType() === "LineString") {
+      geometry.forEachSegment(function(start, end) {
+        var dx = end[0] - start[0];
+        var dy = end[1] - start[1];
+        var rotation = Math.atan2(dy, dx);
+        // arrows
+        styles.push(
+          new Style({
+            geometry: new Point(end),
+            image: new Icon({
+              // we need to make sure the image is loaded by Vue Loader
+              src: require("@/assets/linestring_arrow_grey.png"),
+              // fiddling with the anchor's y value does not help to
+              // position the image more centered on the line ending, as the
+              // default line style seems to be slightly uncentered in the
+              // anti-aliasing, but the image is not placed with subpixel
+              // precision
+              anchor: [0.75, 0.5],
+              rotateWithView: true,
+              rotation: -rotation
+            })
+          })
+        );
+      });
+    }
+    return styles;
+  }
+});
+
+let layerConfigs = {};
+
+export const unsetLayerConfigs = function() {
+  layerConfigs = {};
+};
+
+export const layerFactory = function(mapId) {
+  const styles = styleFactory(mapId);
+  // Shared feature source for layers:
+  // BOTTLENECKS, BOTTLENECKSTATUS and BOTTLENECKFAIRWAYAVAILABILITY
+  // Reduces bottlenecks_geoserver requests and number of stored feature objects.
+  const FDREVIEWLAYER = new VectorLayer({
+    id: "FDREVIEWLAYER",
+    label: "Review",
+    visible: true,
+    source: new VectorSource({ wrapX: false }),
+    style: styles.sections
+  });
+  const bottlenecksSource = new VectorSource({ strategy: bboxStrategy });
+  bottlenecksSource.setLoader(
+    buildVectorLoader(
+      {
+        featureTypes: ["bottlenecks_geoserver"],
+        geometryName: "area"
+      },
+      bottlenecksSource,
+      false,
+      async (f, store) => {
+        if (f.get("fa_critical")) {
+          // look for fairway availability data in store. If present and
+          // not older than 15 min use it or fetch new data and store it.
+          let data = store.getters["fairwayavailability/fwLNWLOverviewData"](f);
+          if (
+            data &&
+            new Date().getTime() - data.createdAt.getTime() < 900000
+          ) {
+            f.set("fa_data", data.data);
+          } else {
+            let date = new Date();
+            data = await store.dispatch(
+              "fairwayavailability/loadAvailableFairwayDepthLNWLForMap",
+              {
+                feature: f,
+                from: date.toISOString().split("T")[0],
+                to: date.toISOString().split("T")[0],
+                frequency: "monthly",
+                LOS: 3
+              }
+            );
+            if (data) {
+              store.commit("fairwayavailability/addFwLNWLOverviewData", {
+                feature: f,
+                data,
+                createdAt: new Date()
+              });
+              f.set("fa_data", data);
+            }
+          }
+        }
+        return f;
+      }
+    )
+  );
+
+  // either use existing config or create new one
+  // important is only each map has its individual layer config
+  // but we don't want to create new layer objects each time a store value
+  // that is used here changes.
+  const config = layerConfigs.hasOwnProperty(mapId)
+    ? layerConfigs[mapId]
+    : [
+        new TileLayer({
+          id: "OPENSTREETMAP",
+          label: "Open Streetmap",
+          visible: true,
+          source: new OSM()
+        }),
+        new ImageLayer({
+          id: "INLANDECDIS",
+          label: "Inland ECDIS chart Danube",
+          visible: true,
+          source: null
+        }),
+        new ImageLayer({
+          id: "WATERWAYAREA",
+          label: "Waterway Area",
+          maxResolution: 100,
+          minResolution: 0,
+          source: new ImageSource({
+            url: window.location.origin + "/api/internal/wms",
+            params: {
+              LAYERS: "waterway_area",
+              VERSION: "1.1.1",
+              TILED: true
+            },
+            imageLoadFunction: function(tile, src) {
+              HTTP.get(src, {
+                headers: {
+                  "X-Gemma-Auth": localStorage.getItem("token")
+                },
+                responseType: "blob"
+              }).then(response => {
+                tile.getImage().src = URL.createObjectURL(response.data);
+              });
+            } // TODO  tile.setState(TileState.ERROR);
+          })
+        }),
+        (function() {
+          const source = new VectorSource({ strategy: bboxStrategy });
+          source.setLoader(
+            buildVectorLoader(
+              {
+                featureTypes: ["stretches_geoserver"],
+                geometryName: "area"
+              },
+              source,
+              true,
+              (f, store) => {
+                if (f.getId() === store.state.imports.selectedStretchId) {
+                  f.set("highlighted", true);
+                }
+                return f;
+              }
+            )
+          );
+          return new VectorLayer({
+            id: "STRETCHES",
+            label: "Stretches",
+            visible: false,
+            style: styles.stretches,
+            source
+          });
+        })(),
+        (function() {
+          const source = new VectorSource({ strategy: bboxStrategy });
+          source.setLoader(
+            buildVectorLoader(
+              {
+                featureTypes: ["sections_geoserver"],
+                geometryName: "area"
+              },
+              source,
+              true,
+              (f, store) => {
+                if (f.getId() === store.state.imports.selectedSectionId) {
+                  f.set("highlighted", true);
+                }
+                return f;
+              }
+            )
+          );
+          return new VectorLayer({
+            id: "SECTIONS",
+            label: "Sections",
+            visible: false,
+            style: styles.sections,
+            source
+          });
+        })(),
+        (function() {
+          return new VectorLayer({
+            id: "BOTTLENECKS",
+            label: "Bottlenecks",
+            visible: true,
+            style: styles.bottleneck,
+            source: bottlenecksSource
+          });
+        })(),
+        new TileLayer({
+          id: "BOTTLENECKISOLINE",
+          label: "Bottleneck morphology",
+          visible: false,
+          source: new TileWMS({
+            preload: 0,
+            projection: "EPSG:3857",
+            url: window.location.origin + "/api/internal/wms",
+            params: {
+              LAYERS: "sounding_results_areas_geoserver",
+              VERSION: "1.1.1",
+              TILED: true
+            },
+            tileLoadFunction: function(tile, src) {
+              HTTP.get(src, {
+                headers: {
+                  "X-Gemma-Auth": localStorage.getItem("token")
+                },
+                responseType: "blob"
+              })
+                .then(response => {
+                  tile.getImage().src = URL.createObjectURL(response.data);
+                })
+                .catch(() => {
+                  store.dispatch("application/reportBackendError");
+                });
+            } // TODO  tile.setState(TileState.ERROR);
+          })
+        }),
+        new TileLayer({
+          id: "DIFFERENCES",
+          label: "Bottleneck Differences",
+          visible: false,
+          source: new TileWMS({
+            preload: 0,
+            projection: "EPSG:3857",
+            url: window.location.origin + "/api/internal/wms",
+            params: {
+              LAYERS: "sounding_differences",
+              VERSION: "1.1.1",
+              TILED: true,
+              CQL_FILTER: "id=" + store.state.fairwayprofile.currentDifference
+            },
+            tileLoadFunction: function(tile, src) {
+              HTTP.get(src, {
+                headers: {
+                  "X-Gemma-Auth": localStorage.getItem("token")
+                },
+                responseType: "blob"
+              })
+                .then(response => {
+                  tile.getImage().src = URL.createObjectURL(response.data);
+                })
+                .catch(() => {
+                  store.dispatch("application/reportBackendError");
+                });
+            } // TODO  tile.setState(TileState.ERROR);
+          })
+        }),
+        (function() {
+          const source = new VectorSource({ strategy: bboxStrategy });
+          source.setLoader(
+            buildVectorLoader(
+              {
+                geometryName: "area",
+                featureTypes: ["fairway_dimensions"],
+                filter: andFilter(
+                  equalTo("level_of_service", 1),
+                  equalTo("staging_done", true)
+                )
+              },
+              source,
+              false
+            )
+          );
+          return new VectorLayer({
+            id: "FAIRWAYDIMENSIONSLOS1",
+            label: "LOS 1 Fairway Dimensions",
+            visible: false,
+            style: styles.fwd1,
+            maxResolution: 80,
+            minResolution: 0,
+            source
+          });
+        })(),
+        (function() {
+          const source = new VectorSource({ strategy: bboxStrategy });
+          source.setLoader(
+            buildVectorLoader(
+              {
+                geometryName: "area",
+                featureTypes: ["fairway_dimensions"],
+                filter: andFilter(
+                  equalTo("level_of_service", 2),
+                  equalTo("staging_done", true)
+                )
+              },
+              source,
+              false
+            )
+          );
+          return new VectorLayer({
+            id: "FAIRWAYDIMENSIONSLOS2",
+            label: "LOS 2 Fairway Dimensions",
+            visible: false,
+            style: styles.fwd2,
+            maxResolution: 80,
+            minResolution: 0,
+            source
+          });
+        })(),
+        (function() {
+          const source = new VectorSource({ strategy: bboxStrategy });
+          source.setLoader(
+            buildVectorLoader(
+              {
+                geometryName: "area",
+                featureTypes: ["fairway_dimensions"],
+                filter: andFilter(
+                  equalTo("level_of_service", 3),
+                  equalTo("staging_done", true)
+                )
+              },
+              source,
+              false
+            )
+          );
+          return new VectorLayer({
+            id: "FAIRWAYDIMENSIONSLOS3",
+            label: "LOS 3 Fairway Dimensions",
+            visible: true,
+            style: styles.fwd3,
+            maxResolution: 80,
+            minResolution: 0,
+            source
+          });
+        })(),
+        new ImageLayer({
+          id: "WATERWAYAXIS",
+          label: "Waterway Axis",
+          source: new ImageSource({
+            url: window.location.origin + "/api/internal/wms",
+            params: {
+              LAYERS: "waterway_axis",
+              VERSION: "1.1.1",
+              TILED: true,
+              TIME: store.state.application.refreshLayersTime.toISOString()
+            },
+            imageLoadFunction: function(tile, src) {
+              HTTP.get(src, {
+                headers: {
+                  "X-Gemma-Auth": localStorage.getItem("token")
+                },
+                responseType: "blob"
+              })
+                .then(response => {
+                  tile.getImage().src = URL.createObjectURL(response.data);
+                })
+                .catch(() => {
+                  store.dispatch("application/reportBackendError");
+                });
+            } // TODO  tile.setState(TileState.ERROR);
+          })
+        }),
+        (function() {
+          const source = new VectorSource({ strategy: bboxStrategy });
+          source.setLoader(
+            buildVectorLoader(
+              {
+                featureTypes: ["waterway_profiles"],
+                geometryName: "geom"
+              },
+              source
+            )
+          );
+          return new VectorLayer({
+            id: "WATERWAYPROFILES",
+            label: "Waterway Profiles",
+            visible: true,
+            style: new Style({
+              stroke: new Stroke({
+                color: "rgba(0, 0, 255, .5)",
+                lineDash: [5, 5],
+                width: 2
+              })
+            }),
+            maxResolution: 2.5,
+            minResolution: 0,
+            source
+          });
+        })(),
+        (function() {
+          return new VectorLayer({
+            id: "BOTTLENECKSTATUS",
+            label: "Critical Bottlenecks",
+            forLegendStyle: { point: true, resolution: 16 },
+            visible: true,
+            zIndex: 1,
+            style: styles.bottleneckStatus,
+            source: bottlenecksSource
+          });
+        })(),
+        (function() {
+          return new VectorLayer({
+            id: "BOTTLENECKFAIRWAYAVAILABILITY",
+            label: "Bottleneck Fairway Availability",
+            forLegendStyle: { point: true, resolution: 16 },
+            visible: false,
+            zIndex: 1,
+            style: styles.bottleneckFairwayAvailability,
+            source: bottlenecksSource
+          });
+        })(),
+        (function() {
+          const source = new VectorSource({ strategy: bboxStrategy });
+          source.setLoader(
+            buildVectorLoader(
+              {
+                featureTypes: [
+                  "bottlenecks_geoserver",
+                  "gauges_geoserver",
+                  "stretches_geoserver",
+                  "sections_geoserver"
+                ]
+              },
+              source,
+              true,
+              // since we don't use bbox strategy, features will contain all features and we can use it
+              // to find reference gauges for bottlenecks, yeah!
+              async (f, store, features) => {
+                // attach reference gauge to bottleneck
+                if (f.getId().indexOf("bottlenecks") > -1) {
+                  f.set(
+                    "gauge_obj",
+                    features.find(feat => {
+                      return (
+                        feat.getId().indexOf("gauges") > -1 &&
+                        feat.get("objname") === f.get("gauge_objname")
+                      );
+                    })
+                  );
+                }
+
+                // attach nsc data to gauge
+                if (f.getId().indexOf("gauges") > -1) {
+                  store
+                    .dispatch(
+                      "gauges/getNashSutcliffeForISRS",
+                      f.get("isrs_code")
+                    )
+                    .then(response => {
+                      f.set("nsc_data", response);
+                    });
+                }
+              }
+            )
+          );
+          return new VectorLayer({
+            id: "DATAAVAILABILITY",
+            label: "Data Availability/Accuracy",
+            forLegendStyle: { point: true, resolution: 16 },
+            visible: false,
+            zIndex: 1,
+            style: styles.dataAvailability,
+            source
+          });
+        })(),
+        new ImageLayer({
+          id: "DISTANCEMARKS",
+          label: "Distance Marks",
+          maxResolution: 10,
+          minResolution: 0,
+          source: new ImageSource({
+            url: window.location.origin + "/api/internal/wms",
+            params: {
+              LAYERS: "distance_marks_ashore_geoserver",
+              VERSION: "1.1.1",
+              TILED: true
+            },
+            imageLoadFunction: function(tile, src) {
+              HTTP.get(src, {
+                headers: {
+                  "X-Gemma-Auth": localStorage.getItem("token")
+                },
+                responseType: "blob"
+              })
+                .then(response => {
+                  tile.getImage().src = URL.createObjectURL(response.data);
+                })
+                .catch(() => {
+                  store.dispatch("application/reportBackendError");
+                });
+            } // TODO  tile.setState(TileState.ERROR);
+          })
+        }),
+        new ImageLayer({
+          id: "DISTANCEMARKSAXIS",
+          label: "Distance Marks, Axis",
+          source: new ImageSource({
+            url: window.location.origin + "/api/internal/wms",
+            params: {
+              LAYERS: "distance_marks_geoserver",
+              VERSION: "1.1.1",
+              TILED: true
+            },
+            imageLoadFunction: function(tile, src) {
+              HTTP.get(src, {
+                headers: {
+                  "X-Gemma-Auth": localStorage.getItem("token")
+                },
+                responseType: "blob"
+              }).then(response => {
+                tile.getImage().src = URL.createObjectURL(response.data);
+              });
+            } // TODO  tile.setState(TileState.ERROR);
+          })
+        }),
+        (function() {
+          const source = new VectorSource({ strategy: bboxStrategy });
+          source.setLoader(
+            buildVectorLoader(
+              {
+                featureTypes: ["gauges_geoserver"],
+                geometryName: "geom"
+              },
+              source
+            )
+          );
+          return new VectorLayer({
+            id: "GAUGES",
+            label: "Gauges",
+            forLegendStyle: { point: true, resolution: 8 },
+            visible: true,
+            style: styles.gauge,
+            maxResolution: 100,
+            minResolution: 0,
+            source
+          });
+        })(),
+        new TileLayer({
+          id: "FAIRWAYMARKS",
+          label: "Fairway marks",
+          visible: true,
+          source: new TileWMS({
+            preload: 0,
+            projection: "EPSG:3857",
+            url: window.location.origin + "/api/internal/wms",
+            params: {
+              LAYERS: "fairway_marks",
+              VERSION: "1.1.1",
+              TILED: true,
+              TIME: store.state.application.refreshLayersTime.toISOString()
+            },
+            tileLoadFunction: function(tile, src) {
+              HTTP.get(src, {
+                headers: {
+                  "X-Gemma-Auth": localStorage.getItem("token")
+                },
+                responseType: "blob"
+              })
+                .then(response => {
+                  tile.getImage().src = URL.createObjectURL(response.data);
+                })
+                .catch(() => {
+                  store.dispatch("application/reportBackendError");
+                });
+            } // TODO  tile.setState(TileState.ERROR);
+          })
+        }),
+        DRAWLAYER,
+        CUTLAYER,
+        FDREVIEWLAYER
+      ];
+
+  layerConfigs[mapId] = config;
+
+  return {
+    get(id) {
+      return config.find(l => l.get("id") === id);
+    },
+    config
+  };
+};
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/client/src/components/layers/styles.js	Fri Mar 27 15:57:40 2020 +0100
@@ -0,0 +1,400 @@
+import { Icon, Stroke, Style, Fill, Text, Circle } from "ol/style";
+import Point from "ol/geom/Point";
+import { getCenter } from "ol/extent";
+import store from "@/store/index";
+import classifications from "../../lib/classifications";
+
+const styles = {
+  blue1: new Style({
+    stroke: new Stroke({
+      color: [0, 0, 255, 0.8],
+      lineDash: [2, 4],
+      lineCap: "round",
+      width: 2
+    }),
+    fill: new Fill({
+      color: [240, 230, 0, 0.2]
+    })
+  }),
+  blue2: new Style({
+    stroke: new Stroke({
+      color: [0, 0, 255, 0.9],
+      lineDash: [3, 6],
+      lineCap: "round",
+      width: 2
+    }),
+    fill: new Fill({
+      color: [240, 230, 0, 0.1]
+    })
+  }),
+  blue3: new Style({
+    stroke: new Stroke({
+      color: [0, 0, 255, 1.0],
+      width: 2
+    }),
+    fill: new Fill({
+      color: [255, 255, 255, 0.4]
+    })
+  }),
+  yellow1: new Style({
+    stroke: new Stroke({
+      color: "rgba(230, 230, 10, .8)",
+      width: 4
+    }),
+    fill: new Fill({
+      color: "rgba(230, 230, 10, .3)"
+    })
+  }),
+  yellow2: new Style({
+    stroke: new Stroke({
+      color: "rgba(250, 200, 0, .8)",
+      width: 2
+    }),
+    fill: new Fill({
+      color: "rgba(250, 200, 10, .3)"
+    })
+  }),
+  yellow3: new Style({
+    stroke: new Stroke({
+      color: "rgba(250, 240, 10, .9)",
+      width: 5
+    }),
+    fill: new Fill({
+      color: "rgba(250, 240, 0, .7)"
+    })
+  }),
+  orange1: new Style({
+    stroke: new Stroke({
+      color: "rgba(255, 150, 10, .8)",
+      width: 2
+    }),
+    fill: new Fill({
+      color: "rgba(255, 150, 0, .3)"
+    })
+  }),
+  orange2: new Style({
+    stroke: new Stroke({
+      color: "rgba(255, 166, 10, .9)",
+      width: 5
+    }),
+    fill: new Fill({
+      color: "rgba(255, 166, 0, .7)"
+    })
+  }),
+  red1: new Style({
+    stroke: new Stroke({
+      color: "rgba(255, 0, 0, 1)",
+      width: 4
+    })
+  }),
+  circleBlue: new Style({
+    image: new Circle({
+      radius: 5,
+      fill: new Fill({ color: "rgba(255, 0, 0, 0.1)" }),
+      stroke: new Stroke({ color: "blue", width: 1 })
+    })
+  }),
+  textFW1: new Style({
+    text: new Text({
+      font: 'bold 12px "Open Sans", "sans-serif"',
+      placement: "line",
+      fill: new Fill({
+        color: "black"
+      }),
+      text: "LOS: 1"
+      //, zIndex: 10
+    })
+  }),
+  textFW2: new Style({
+    text: new Text({
+      font: 'bold 12px "Open Sans", "sans-serif"',
+      placement: "line",
+      fill: new Fill({
+        color: "black"
+      }),
+      text: "LOS: 2"
+      //, zIndex: 10
+    })
+  }),
+  textFW3: new Style({
+    text: new Text({
+      font: 'bold 12px "Open Sans", "sans-serif"',
+      placement: "line",
+      fill: new Fill({
+        color: "black"
+      }),
+      text: "LOS: 3"
+      //, zIndex: 10
+    })
+  })
+};
+
+const styleFactory = function(mapId) {
+  const recencyColorCodes = {
+    OK: "lime",
+    WARNING: "yellow",
+    DANGER: "red",
+    NEUTRAL: "white"
+  };
+  const gmAvailabilityColorCodes = {
+    OK: "lime",
+    WARNING: "yellow",
+    DANGER: "red",
+    NEUTRAL: "white"
+  };
+  const forecastAccuracyColorCodes = {
+    OK: "lime",
+    WARNING: "yellow",
+    DANGER: "red",
+    NEUTRAL: "white"
+  };
+
+  const forecastVsRealityColorCodes = {
+    OK: "lime",
+    WARNING: "yellow",
+    DANGER: "red",
+    NEUTRAL: "white"
+  };
+  return {
+    recencyColorCodes: recencyColorCodes,
+    gmAvailabilityColorCodes: gmAvailabilityColorCodes,
+    forecastAccuracyColorCodes: forecastAccuracyColorCodes,
+    forecastVsRealityColorCodes: forecastVsRealityColorCodes,
+    stretches(feature) {
+      let style = styles.yellow2;
+      if (feature.get("highlighted")) {
+        style = styles.yellow3;
+      }
+      return style;
+    },
+    sections(feature) {
+      let style = styles.orange1;
+      if (feature.get("highlighted")) {
+        style = styles.orange2;
+      }
+      return style;
+    },
+    fwd1() {
+      return [styles.blue1, styles.textFW1];
+    },
+    fwd2() {
+      return [styles.blue2, styles.textFW2];
+    },
+    fwd3() {
+      return [styles.blue3, styles.textFW3];
+    },
+    bottleneck() {
+      return styles.yellow1;
+    },
+    bottleneckStatus(feature, resolution, isLegend) {
+      let s = [];
+      if ((feature.get("fa_critical") && resolution > 15) || isLegend) {
+        let bnCenter = getCenter(feature.getGeometry().getExtent());
+        s.push(
+          new Style({
+            geometry: new Point(bnCenter),
+            image: new Icon({
+              src: require("@/assets/marker-bottleneck-critical.png"),
+              anchor: [0.5, 0.5],
+              scale: isLegend ? 0.5 : 1
+            })
+          })
+        );
+      }
+      if (feature.get("fa_critical") && !isLegend) {
+        s.push(styles.red1);
+      }
+      return s;
+    },
+    bottleneckFairwayAvailability(feature, resolution, isLegend) {
+      let s = [];
+      if (isLegend) {
+        s.push(
+          new Style({
+            image: new Icon({
+              src: require("@/assets/fa-diagram.png"),
+              anchor: [0.5, 0.5],
+              scale: 1
+            })
+          })
+        );
+      }
+      if (feature.get("fa_critical") && feature.get("fa_data")) {
+        let data = feature.get("fa_data");
+        const heightInPixel = 80;
+        const relativeHeightInPercent = heightInPixel / 100;
+        let lnwlHeight = relativeHeightInPercent * data.ldc;
+        let belowThresholdHeight = relativeHeightInPercent * data.below;
+        let betweenThresholdHeight = relativeHeightInPercent * data.between;
+        let aboveThresholdHeight = relativeHeightInPercent * data.above;
+        let lnwl = `<rect x='2' y='${2 +
+          heightInPixel -
+          lnwlHeight}' width='10' height='${lnwlHeight}' stroke-width='0' fill='aqua'/>`;
+        let above = `<rect x='12' y='${2 +
+          heightInPixel -
+          aboveThresholdHeight}' width='18' height='${aboveThresholdHeight}' stroke-width='0' fill='blue'/>`;
+        let between = `<rect x='12' y='${2 +
+          heightInPixel -
+          aboveThresholdHeight -
+          betweenThresholdHeight}' width='18' height='${betweenThresholdHeight}' stroke-width='0' fill='darksalmon'/>`;
+        let below = `<rect x='12' y='${2 +
+          heightInPixel -
+          aboveThresholdHeight -
+          betweenThresholdHeight -
+          belowThresholdHeight}' width='18' height='${belowThresholdHeight}' stroke-width='0' fill='hotpink'/>`;
+        let frame = `<rect x='0' y='0' width='32' height='84' stroke-width='0' fill='white'/>`;
+        let svg = `data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' width='32' height='84'><g>${frame}${lnwl}${above}${between}${below}</g></svg>`;
+        let bnCenter = getCenter(feature.getGeometry().getExtent());
+        s.push(
+          new Style({
+            geometry: new Point(bnCenter),
+            image: new Icon({
+              src: svg,
+              anchor: [1.2, 1.2]
+            })
+          })
+        );
+      }
+      return s;
+    },
+    dataAvailability(feature, resolution, isLegend) {
+      let s = [];
+      if (isLegend) {
+        s.push(
+          new Style({
+            image: new Icon({
+              src: require("@/assets/da-diagram.png"),
+              anchor: [0.5, 0.5],
+              scale: 1
+            })
+          })
+        );
+      } else {
+        // TODO: Get information from feature and check the ranges according to #423, #424, #425
+        let colorWaterlevel =
+          gmAvailabilityColorCodes[classifications.gmAvailability(feature)];
+        let colorComparison =
+          forecastVsRealityColorCodes[
+            classifications.forecastVsReality(feature)
+          ];
+        let colorAccuracy =
+          forecastAccuracyColorCodes[classifications.forecastAccuracy(feature)];
+        let map = store.getters["map/openLayersMap"](mapId);
+        let geom = feature.getGeometry();
+        if (!(geom instanceof Point)) {
+          geom = new Point(getCenter(feature.getGeometry().getExtent()));
+        }
+        if (
+          (map.getLayer("BOTTLENECKS").getVisible() &&
+            feature.getId().indexOf("bottlenecks") > -1) ||
+          (map.getLayer("SECTIONS").getVisible() &&
+            feature.getId().indexOf("sections") > -1) ||
+          (map.getLayer("STRETCHES").getVisible() &&
+            feature.getId().indexOf("stretches") > -1) ||
+          (map.getLayer("GAUGES").getVisible() &&
+            feature.getId().indexOf("gauges") > -1)
+        ) {
+          let frame = `<polyline points='16,0 32,28 0,28 16,0' stroke='grey' stroke-width='1' fill='white'/>`;
+          let waterlevel = `<polyline points="16,0 24,14 16,28 8,14 16,0" stroke='grey' stroke-width='1' fill='${colorWaterlevel}'/>`;
+          let accuracy = `<polyline points="24,14 32,28 16,28 24,14" stroke='grey' stroke-width='1' fill='${colorAccuracy}'/>`;
+          let comparison = `<polyline points="8,14 16,28 0,28 8,14" stroke='grey' stroke-width='1' fill='${colorComparison}'/>`;
+          let svg = `data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' width='32' height='28'><g>${frame}${waterlevel}${comparison}${accuracy}</g></svg>`;
+          s.push(
+            new Style({
+              geometry: geom,
+              image: new Icon({
+                src: svg,
+                anchor: [-0.5, 1]
+              })
+            })
+          );
+        }
+
+        if (
+          map.getLayer("BOTTLENECKS").getVisible() &&
+          feature.getId().indexOf("bottlenecks") > -1
+        ) {
+          let colorUniformTriangle =
+            recencyColorCodes[classifications.surveyRecency(feature)];
+          let frame = `<polyline points='16,0 32,28 0,28 16,0' stroke='grey' stroke-width='1' fill='${colorUniformTriangle}'/>`;
+          let svg = `data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' width='32' height='28'><g>${frame}</g></svg>`;
+          s.push(
+            new Style({
+              geometry: geom,
+              image: new Icon({
+                src: svg,
+                anchor: [0.5, 1]
+              })
+            })
+          );
+        }
+      }
+      return s;
+    },
+    dma(feature, resolution) {
+      if (resolution < 10) {
+        var s = styles.circleBlue;
+        if (resolution < 6) {
+          s.setText(
+            new Text({
+              offsetY: 12,
+              font: '10px "Open Sans", "sans-serif"',
+              fill: new Fill({
+                color: "black"
+              }),
+              text: (feature.get("hectometre") / 10).toString()
+            })
+          );
+        }
+        return s;
+      }
+      return [];
+    },
+    gauge(feature, resolution, isLegend) {
+      let waterlevel = feature.get("gm_waterlevel");
+      let text = feature.get("objname");
+      let iconColor = "white";
+      if (waterlevel) {
+        text += "\n(" + waterlevel + " cm)";
+        let refWaterlevels = JSON.parse(feature.get("reference_water_levels"));
+        if (refWaterlevels) {
+          const HDC =
+            refWaterlevels[
+              Object.keys(refWaterlevels).find(e => /HDC/.test(e))
+            ];
+          const LDC =
+            refWaterlevels[
+              Object.keys(refWaterlevels).find(e => /LDC/.test(e))
+            ];
+          if (waterlevel < LDC) iconColor = "brown";
+          if (waterlevel > LDC && waterlevel < HDC) iconColor = "blue";
+          if (waterlevel > HDC) iconColor = "red";
+        }
+      }
+
+      return [
+        new Style({
+          image: new Icon({
+            src: require("@/assets/marker-gauge-" + iconColor + ".png"),
+            anchor: [0.5, isLegend ? 0.5 : 1],
+            scale: isLegend ? 0.5 : 1
+          }),
+          text: new Text({
+            font: '10px "Open Sans", "sans-serif"',
+            offsetY: 15,
+            fill: new Fill({
+              color: "black"
+            }),
+            backgroundFill: new Fill({
+              color: "rgba(255, 255, 255, 0.7)"
+            }),
+            padding: [2, 2, 2, 2],
+            text
+          })
+        })
+      ];
+    }
+  };
+};
+
+export { styles, styleFactory };
--- a/client/src/components/map/Map.vue	Mon Mar 23 15:29:55 2020 +0100
+++ b/client/src/components/map/Map.vue	Fri Mar 27 15:57:40 2020 +0100
@@ -41,9 +41,9 @@
 import { Stroke, Style, Fill } from "ol/style";
 import { displayError } from "@/lib/errors";
 import { pane } from "@/lib/mixins";
-import { layerFactory } from "@/components/map/layers";
+import { layerFactory } from "@/components/layers/layers";
 import { ImageWMS as ImageSource } from "ol/source";
-import { styles } from "./styles";
+import { styles } from "@/components/layers/styles";
 import "ol/ol.css";
 
 /* for the sake of debugging */
--- a/client/src/components/map/Zoom.vue	Mon Mar 23 15:29:55 2020 +0100
+++ b/client/src/components/map/Zoom.vue	Fri Mar 27 15:57:40 2020 +0100
@@ -1,5 +1,5 @@
 <template>
-  <div class="zoom-buttons shadow-xs">
+  <div :class="['zoom-buttons shadow-xs', { move: showTimeSlider }]">
     <button
       class="zoom-button border-0 bg-white rounded-left ui-element"
       @click="zoomOut"
@@ -24,7 +24,8 @@
   margin-left: -$icon-width
   margin-bottom: 0
   transition: margin-bottom 0.3s
-
+  &.move
+    bottom: $large-offset * 1.5
   .zoom-button
     min-height: $icon-width
     min-width: $icon-width
@@ -34,6 +35,7 @@
 </style>
 
 <script>
+import { mapState } from "vuex";
 /* This is Free Software under GNU Affero General Public License v >= 3.0
  * without warranty, see README.md and license for details.
  *
@@ -51,6 +53,7 @@
 export default {
   props: ["map"],
   computed: {
+    ...mapState("application", ["showTimeSlider"]),
     zoomLevel: {
       get() {
         return this.map.getView().getZoom();
--- a/client/src/components/map/layers.js	Mon Mar 23 15:29:55 2020 +0100
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,722 +0,0 @@
-import { GeoJSON, WFS } from "ol/format";
-import { Icon, Stroke, Style } from "ol/style";
-import {
-  Image as ImageLayer,
-  Tile as TileLayer,
-  Vector as VectorLayer
-} from "ol/layer";
-import { and as andFilter, equalTo } from "ol/format/filter";
-
-import { HTTP } from "@/lib/http";
-import { ImageWMS as ImageSource } from "ol/source";
-import OSM from "ol/source/OSM";
-import Point from "ol/geom/Point";
-import TileWMS from "ol/source/TileWMS";
-import VectorSource from "ol/source/Vector";
-import { bbox as bboxStrategy } from "ol/loadingstrategy";
-import store from "@/store/index";
-import { styleFactory } from "./styles";
-
-export const buildVectorLoader = (
-  featureRequestOptions,
-  vectorSource,
-  bboxStrategyDisabled,
-  featurePostProcessor
-) => {
-  // build a function to be used for VectorSource.setLoader()
-  // make use of WFS().writeGetFeature to build the request
-  // and use our HTTP library to actually do it
-  // NOTE: the geometryName has to be given in featureRequestOptions if
-  // bboxStrategy (default) is used
-  featureRequestOptions.featureNS = "gemma";
-  featureRequestOptions.featurePrefix = "gemma";
-  featureRequestOptions.outputFormat = "application/json";
-  return (extent, resolution, projection) => {
-    if (!bboxStrategyDisabled) {
-      featureRequestOptions.bbox = extent;
-    }
-    featureRequestOptions.srsName = projection.getCode();
-    HTTP.post(
-      "/internal/wfs",
-      new XMLSerializer().serializeToString(
-        new WFS().writeGetFeature(featureRequestOptions)
-      ),
-      {
-        headers: {
-          "X-Gemma-Auth": localStorage.getItem("token"),
-          "Content-type": "text/xml; charset=UTF-8"
-        }
-      }
-    )
-      .then(response => {
-        const features = new GeoJSON().readFeatures(
-          JSON.stringify(response.data)
-        );
-        if (featurePostProcessor) {
-          features.map(f => featurePostProcessor(f, store, features));
-        }
-        vectorSource.addFeatures(features);
-      })
-      .catch(() => {
-        vectorSource.removeLoadedExtent(extent);
-        store.dispatch("application/reportBackendError");
-      });
-  };
-};
-
-// SHARED LAYERS:
-// DRAW- and CUTLAYER are shared across maps. E.g. you want to see the cross cut
-// arrow on both maps when comparing surveys. So we don't need to initialize a
-// new VectorLayer object for each map. Instead we use these two constants so
-// that all maps use the same object.
-const DRAWLAYER = new VectorLayer({
-  id: "DRAWTOOL",
-  label: "Draw Tool",
-  visible: true,
-  source: new VectorSource({ wrapX: false }),
-  style: function(feature) {
-    // adapted from OpenLayer's LineString Arrow Example
-    var geometry = feature.getGeometry();
-    var styles = [
-      // linestring
-      new Style({
-        stroke: new Stroke({
-          color: "#369aca",
-          width: 2
-        })
-      })
-    ];
-
-    if (geometry.getType() === "LineString") {
-      geometry.forEachSegment(function(start, end) {
-        var dx = end[0] - start[0];
-        var dy = end[1] - start[1];
-        var rotation = Math.atan2(dy, dx);
-        // arrows
-        styles.push(
-          new Style({
-            geometry: new Point(end),
-            image: new Icon({
-              // we need to make sure the image is loaded by Vue Loader
-              src: require("@/assets/linestring_arrow.png"),
-              // fiddling with the anchor's y value does not help to
-              // position the image more centered on the line ending, as the
-              // default line style seems to be slightly uncentered in the
-              // anti-aliasing, but the image is not placed with subpixel
-              // precision
-              anchor: [0.75, 0.5],
-              rotateWithView: true,
-              rotation: -rotation
-            })
-          })
-        );
-      });
-    }
-    return styles;
-  }
-});
-
-const CUTLAYER = new VectorLayer({
-  id: "CUTTOOL",
-  label: "Cut Tool",
-  visible: true,
-  source: new VectorSource({ wrapX: false }),
-  style: function(feature) {
-    // adapted from OpenLayer's LineString Arrow Example
-    var geometry = feature.getGeometry();
-    var styles = [
-      // linestring
-      new Style({
-        stroke: new Stroke({
-          color: "#FFFFFF",
-          width: 5,
-          lineDash: [7, 7]
-        })
-      }),
-      new Style({
-        stroke: new Stroke({
-          color: "#333333",
-          width: 3,
-          lineDash: [7, 7]
-        })
-      })
-    ];
-
-    if (geometry.getType() === "LineString") {
-      geometry.forEachSegment(function(start, end) {
-        var dx = end[0] - start[0];
-        var dy = end[1] - start[1];
-        var rotation = Math.atan2(dy, dx);
-        // arrows
-        styles.push(
-          new Style({
-            geometry: new Point(end),
-            image: new Icon({
-              // we need to make sure the image is loaded by Vue Loader
-              src: require("@/assets/linestring_arrow_grey.png"),
-              // fiddling with the anchor's y value does not help to
-              // position the image more centered on the line ending, as the
-              // default line style seems to be slightly uncentered in the
-              // anti-aliasing, but the image is not placed with subpixel
-              // precision
-              anchor: [0.75, 0.5],
-              rotateWithView: true,
-              rotation: -rotation
-            })
-          })
-        );
-      });
-    }
-    return styles;
-  }
-});
-
-let layerConfigs = {};
-
-export const unsetLayerConfigs = function() {
-  layerConfigs = {};
-};
-
-export const layerFactory = function(mapId) {
-  const styles = styleFactory(mapId);
-  // Shared feature source for layers:
-  // BOTTLENECKS, BOTTLENECKSTATUS and BOTTLENECKFAIRWAYAVAILABILITY
-  // Reduces bottlenecks_geoserver requests and number of stored feature objects.
-  const FDREVIEWLAYER = new VectorLayer({
-    id: "FDREVIEWLAYER",
-    label: "Review",
-    visible: true,
-    source: new VectorSource({ wrapX: false }),
-    style: styles.sections
-  });
-  const bottlenecksSource = new VectorSource({ strategy: bboxStrategy });
-  bottlenecksSource.setLoader(
-    buildVectorLoader(
-      {
-        featureTypes: ["bottlenecks_geoserver"],
-        geometryName: "area"
-      },
-      bottlenecksSource,
-      false,
-      async (f, store) => {
-        if (f.get("fa_critical")) {
-          // look for fairway availability data in store. If present and
-          // not older than 15 min use it or fetch new data and store it.
-          let data = store.getters["fairwayavailability/fwLNWLOverviewData"](f);
-          if (
-            data &&
-            new Date().getTime() - data.createdAt.getTime() < 900000
-          ) {
-            f.set("fa_data", data.data);
-          } else {
-            let date = new Date();
-            data = await store.dispatch(
-              "fairwayavailability/loadAvailableFairwayDepthLNWLForMap",
-              {
-                feature: f,
-                from: date.toISOString().split("T")[0],
-                to: date.toISOString().split("T")[0],
-                frequency: "monthly",
-                LOS: 3
-              }
-            );
-            if (data) {
-              store.commit("fairwayavailability/addFwLNWLOverviewData", {
-                feature: f,
-                data,
-                createdAt: new Date()
-              });
-              f.set("fa_data", data);
-            }
-          }
-        }
-        return f;
-      }
-    )
-  );
-
-  // either use existing config or create new one
-  // important is only each map has its individual layer config
-  // but we don't want to create new layer objects each time a store value
-  // that is used here changes.
-  const config = layerConfigs.hasOwnProperty(mapId)
-    ? layerConfigs[mapId]
-    : [
-        new TileLayer({
-          id: "OPENSTREETMAP",
-          label: "Open Streetmap",
-          visible: true,
-          source: new OSM()
-        }),
-        new ImageLayer({
-          id: "INLANDECDIS",
-          label: "Inland ECDIS chart Danube",
-          visible: true,
-          source: null
-        }),
-        new ImageLayer({
-          id: "WATERWAYAREA",
-          label: "Waterway Area",
-          maxResolution: 100,
-          minResolution: 0,
-          source: new ImageSource({
-            url: window.location.origin + "/api/internal/wms",
-            params: {
-              LAYERS: "waterway_area",
-              VERSION: "1.1.1",
-              TILED: true
-            },
-            imageLoadFunction: function(tile, src) {
-              HTTP.get(src, {
-                headers: {
-                  "X-Gemma-Auth": localStorage.getItem("token")
-                },
-                responseType: "blob"
-              }).then(response => {
-                tile.getImage().src = URL.createObjectURL(response.data);
-              });
-            } // TODO  tile.setState(TileState.ERROR);
-          })
-        }),
-        (function() {
-          const source = new VectorSource({ strategy: bboxStrategy });
-          source.setLoader(
-            buildVectorLoader(
-              {
-                featureTypes: ["stretches_geoserver"],
-                geometryName: "area"
-              },
-              source,
-              true,
-              (f, store) => {
-                if (f.getId() === store.state.imports.selectedStretchId) {
-                  f.set("highlighted", true);
-                }
-                return f;
-              }
-            )
-          );
-          return new VectorLayer({
-            id: "STRETCHES",
-            label: "Stretches",
-            visible: false,
-            style: styles.stretches,
-            source
-          });
-        })(),
-        (function() {
-          const source = new VectorSource({ strategy: bboxStrategy });
-          source.setLoader(
-            buildVectorLoader(
-              {
-                featureTypes: ["sections_geoserver"],
-                geometryName: "area"
-              },
-              source,
-              true,
-              (f, store) => {
-                if (f.getId() === store.state.imports.selectedSectionId) {
-                  f.set("highlighted", true);
-                }
-                return f;
-              }
-            )
-          );
-          return new VectorLayer({
-            id: "SECTIONS",
-            label: "Sections",
-            visible: false,
-            style: styles.sections,
-            source
-          });
-        })(),
-        (function() {
-          return new VectorLayer({
-            id: "BOTTLENECKS",
-            label: "Bottlenecks",
-            visible: true,
-            style: styles.bottleneck,
-            source: bottlenecksSource
-          });
-        })(),
-        new TileLayer({
-          id: "BOTTLENECKISOLINE",
-          label: "Bottleneck morphology",
-          visible: false,
-          source: new TileWMS({
-            preload: 0,
-            projection: "EPSG:3857",
-            url: window.location.origin + "/api/internal/wms",
-            params: {
-              LAYERS: "sounding_results_areas_geoserver",
-              VERSION: "1.1.1",
-              TILED: true
-            },
-            tileLoadFunction: function(tile, src) {
-              HTTP.get(src, {
-                headers: {
-                  "X-Gemma-Auth": localStorage.getItem("token")
-                },
-                responseType: "blob"
-              })
-                .then(response => {
-                  tile.getImage().src = URL.createObjectURL(response.data);
-                })
-                .catch(() => {
-                  store.dispatch("application/reportBackendError");
-                });
-            } // TODO  tile.setState(TileState.ERROR);
-          })
-        }),
-        new TileLayer({
-          id: "DIFFERENCES",
-          label: "Bottleneck Differences",
-          visible: false,
-          source: new TileWMS({
-            preload: 0,
-            projection: "EPSG:3857",
-            url: window.location.origin + "/api/internal/wms",
-            params: {
-              LAYERS: "sounding_differences",
-              VERSION: "1.1.1",
-              TILED: true,
-              CQL_FILTER: "id=" + store.state.fairwayprofile.currentDifference
-            },
-            tileLoadFunction: function(tile, src) {
-              HTTP.get(src, {
-                headers: {
-                  "X-Gemma-Auth": localStorage.getItem("token")
-                },
-                responseType: "blob"
-              })
-                .then(response => {
-                  tile.getImage().src = URL.createObjectURL(response.data);
-                })
-                .catch(() => {
-                  store.dispatch("application/reportBackendError");
-                });
-            } // TODO  tile.setState(TileState.ERROR);
-          })
-        }),
-        (function() {
-          const source = new VectorSource({ strategy: bboxStrategy });
-          source.setLoader(
-            buildVectorLoader(
-              {
-                geometryName: "area",
-                featureTypes: ["fairway_dimensions"],
-                filter: andFilter(
-                  equalTo("level_of_service", 1),
-                  equalTo("staging_done", true)
-                )
-              },
-              source,
-              false
-            )
-          );
-          return new VectorLayer({
-            id: "FAIRWAYDIMENSIONSLOS1",
-            label: "LOS 1 Fairway Dimensions",
-            visible: false,
-            style: styles.fwd1,
-            maxResolution: 80,
-            minResolution: 0,
-            source
-          });
-        })(),
-        (function() {
-          const source = new VectorSource({ strategy: bboxStrategy });
-          source.setLoader(
-            buildVectorLoader(
-              {
-                geometryName: "area",
-                featureTypes: ["fairway_dimensions"],
-                filter: andFilter(
-                  equalTo("level_of_service", 2),
-                  equalTo("staging_done", true)
-                )
-              },
-              source,
-              false
-            )
-          );
-          return new VectorLayer({
-            id: "FAIRWAYDIMENSIONSLOS2",
-            label: "LOS 2 Fairway Dimensions",
-            visible: false,
-            style: styles.fwd2,
-            maxResolution: 80,
-            minResolution: 0,
-            source
-          });
-        })(),
-        (function() {
-          const source = new VectorSource({ strategy: bboxStrategy });
-          source.setLoader(
-            buildVectorLoader(
-              {
-                geometryName: "area",
-                featureTypes: ["fairway_dimensions"],
-                filter: andFilter(
-                  equalTo("level_of_service", 3),
-                  equalTo("staging_done", true)
-                )
-              },
-              source,
-              false
-            )
-          );
-          return new VectorLayer({
-            id: "FAIRWAYDIMENSIONSLOS3",
-            label: "LOS 3 Fairway Dimensions",
-            visible: true,
-            style: styles.fwd3,
-            maxResolution: 80,
-            minResolution: 0,
-            source
-          });
-        })(),
-        new ImageLayer({
-          id: "WATERWAYAXIS",
-          label: "Waterway Axis",
-          source: new ImageSource({
-            url: window.location.origin + "/api/internal/wms",
-            params: {
-              LAYERS: "waterway_axis",
-              VERSION: "1.1.1",
-              TILED: true
-            },
-            imageLoadFunction: function(tile, src) {
-              HTTP.get(src, {
-                headers: {
-                  "X-Gemma-Auth": localStorage.getItem("token")
-                },
-                responseType: "blob"
-              })
-                .then(response => {
-                  tile.getImage().src = URL.createObjectURL(response.data);
-                })
-                .catch(() => {
-                  store.dispatch("application/reportBackendError");
-                });
-            } // TODO  tile.setState(TileState.ERROR);
-          })
-        }),
-        (function() {
-          const source = new VectorSource({ strategy: bboxStrategy });
-          source.setLoader(
-            buildVectorLoader(
-              {
-                featureTypes: ["waterway_profiles"],
-                geometryName: "geom"
-              },
-              source
-            )
-          );
-          return new VectorLayer({
-            id: "WATERWAYPROFILES",
-            label: "Waterway Profiles",
-            visible: true,
-            style: new Style({
-              stroke: new Stroke({
-                color: "rgba(0, 0, 255, .5)",
-                lineDash: [5, 5],
-                width: 2
-              })
-            }),
-            maxResolution: 2.5,
-            minResolution: 0,
-            source
-          });
-        })(),
-        (function() {
-          return new VectorLayer({
-            id: "BOTTLENECKSTATUS",
-            label: "Critical Bottlenecks",
-            forLegendStyle: { point: true, resolution: 16 },
-            visible: true,
-            zIndex: 1,
-            style: styles.bottleneckStatus,
-            source: bottlenecksSource
-          });
-        })(),
-        (function() {
-          return new VectorLayer({
-            id: "BOTTLENECKFAIRWAYAVAILABILITY",
-            label: "Bottleneck Fairway Availability",
-            forLegendStyle: { point: true, resolution: 16 },
-            visible: false,
-            zIndex: 1,
-            style: styles.bottleneckFairwayAvailability,
-            source: bottlenecksSource
-          });
-        })(),
-        (function() {
-          const source = new VectorSource({ strategy: bboxStrategy });
-          source.setLoader(
-            buildVectorLoader(
-              {
-                featureTypes: [
-                  "bottlenecks_geoserver",
-                  "gauges_geoserver",
-                  "stretches_geoserver",
-                  "sections_geoserver"
-                ]
-              },
-              source,
-              true,
-              // since we don't use bbox strategy, features will contain all features and we can use it
-              // to find reference gauges for bottlenecks, yeah!
-              async (f, store, features) => {
-                // attach reference gauge to bottleneck
-                if (f.getId().indexOf("bottlenecks") > -1) {
-                  f.set(
-                    "gauge_obj",
-                    features.find(feat => {
-                      return (
-                        feat.getId().indexOf("gauges") > -1 &&
-                        feat.get("objname") === f.get("gauge_objname")
-                      );
-                    })
-                  );
-                }
-
-                // attach nsc data to gauge
-                if (f.getId().indexOf("gauges") > -1) {
-                  store
-                    .dispatch(
-                      "gauges/getNashSutcliffeForISRS",
-                      f.get("isrs_code")
-                    )
-                    .then(response => {
-                      f.set("nsc_data", response);
-                    });
-                }
-              }
-            )
-          );
-          return new VectorLayer({
-            id: "DATAAVAILABILITY",
-            label: "Data Availability/Accuracy",
-            forLegendStyle: { point: true, resolution: 16 },
-            visible: false,
-            zIndex: 1,
-            style: styles.dataAvailability,
-            source
-          });
-        })(),
-        new ImageLayer({
-          id: "DISTANCEMARKS",
-          label: "Distance Marks",
-          maxResolution: 10,
-          minResolution: 0,
-          source: new ImageSource({
-            url: window.location.origin + "/api/internal/wms",
-            params: {
-              LAYERS: "distance_marks_ashore_geoserver",
-              VERSION: "1.1.1",
-              TILED: true
-            },
-            imageLoadFunction: function(tile, src) {
-              HTTP.get(src, {
-                headers: {
-                  "X-Gemma-Auth": localStorage.getItem("token")
-                },
-                responseType: "blob"
-              })
-                .then(response => {
-                  tile.getImage().src = URL.createObjectURL(response.data);
-                })
-                .catch(() => {
-                  store.dispatch("application/reportBackendError");
-                });
-            } // TODO  tile.setState(TileState.ERROR);
-          })
-        }),
-        new ImageLayer({
-          id: "DISTANCEMARKSAXIS",
-          label: "Distance Marks, Axis",
-          source: new ImageSource({
-            url: window.location.origin + "/api/internal/wms",
-            params: {
-              LAYERS: "distance_marks_geoserver",
-              VERSION: "1.1.1",
-              TILED: true
-            },
-            imageLoadFunction: function(tile, src) {
-              HTTP.get(src, {
-                headers: {
-                  "X-Gemma-Auth": localStorage.getItem("token")
-                },
-                responseType: "blob"
-              }).then(response => {
-                tile.getImage().src = URL.createObjectURL(response.data);
-              });
-            } // TODO  tile.setState(TileState.ERROR);
-          })
-        }),
-        (function() {
-          const source = new VectorSource({ strategy: bboxStrategy });
-          source.setLoader(
-            buildVectorLoader(
-              {
-                featureTypes: ["gauges_geoserver"],
-                geometryName: "geom"
-              },
-              source
-            )
-          );
-          return new VectorLayer({
-            id: "GAUGES",
-            label: "Gauges",
-            forLegendStyle: { point: true, resolution: 8 },
-            visible: true,
-            style: styles.gauge,
-            maxResolution: 100,
-            minResolution: 0,
-            source
-          });
-        })(),
-        new TileLayer({
-          id: "FAIRWAYMARKS",
-          label: "Fairway marks",
-          visible: true,
-          source: new TileWMS({
-            preload: 0,
-            projection: "EPSG:3857",
-            url: window.location.origin + "/api/internal/wms",
-            params: {
-              LAYERS: "fairway_marks",
-              VERSION: "1.1.1",
-              TILED: true
-            },
-            tileLoadFunction: function(tile, src) {
-              HTTP.get(src, {
-                headers: {
-                  "X-Gemma-Auth": localStorage.getItem("token")
-                },
-                responseType: "blob"
-              })
-                .then(response => {
-                  tile.getImage().src = URL.createObjectURL(response.data);
-                })
-                .catch(() => {
-                  store.dispatch("application/reportBackendError");
-                });
-            } // TODO  tile.setState(TileState.ERROR);
-          })
-        }),
-        DRAWLAYER,
-        CUTLAYER,
-        FDREVIEWLAYER
-      ];
-
-  layerConfigs[mapId] = config;
-
-  return {
-    get(id) {
-      return config.find(l => l.get("id") === id);
-    },
-    config
-  };
-};
--- a/client/src/components/map/styles.js	Mon Mar 23 15:29:55 2020 +0100
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,400 +0,0 @@
-import { Icon, Stroke, Style, Fill, Text, Circle } from "ol/style";
-import Point from "ol/geom/Point";
-import { getCenter } from "ol/extent";
-import store from "@/store/index";
-import classifications from "../../lib/classifications";
-
-const styles = {
-  blue1: new Style({
-    stroke: new Stroke({
-      color: [0, 0, 255, 0.8],
-      lineDash: [2, 4],
-      lineCap: "round",
-      width: 2
-    }),
-    fill: new Fill({
-      color: [240, 230, 0, 0.2]
-    })
-  }),
-  blue2: new Style({
-    stroke: new Stroke({
-      color: [0, 0, 255, 0.9],
-      lineDash: [3, 6],
-      lineCap: "round",
-      width: 2
-    }),
-    fill: new Fill({
-      color: [240, 230, 0, 0.1]
-    })
-  }),
-  blue3: new Style({
-    stroke: new Stroke({
-      color: [0, 0, 255, 1.0],
-      width: 2
-    }),
-    fill: new Fill({
-      color: [255, 255, 255, 0.4]
-    })
-  }),
-  yellow1: new Style({
-    stroke: new Stroke({
-      color: "rgba(230, 230, 10, .8)",
-      width: 4
-    }),
-    fill: new Fill({
-      color: "rgba(230, 230, 10, .3)"
-    })
-  }),
-  yellow2: new Style({
-    stroke: new Stroke({
-      color: "rgba(250, 200, 0, .8)",
-      width: 2
-    }),
-    fill: new Fill({
-      color: "rgba(250, 200, 10, .3)"
-    })
-  }),
-  yellow3: new Style({
-    stroke: new Stroke({
-      color: "rgba(250, 240, 10, .9)",
-      width: 5
-    }),
-    fill: new Fill({
-      color: "rgba(250, 240, 0, .7)"
-    })
-  }),
-  orange1: new Style({
-    stroke: new Stroke({
-      color: "rgba(255, 150, 10, .8)",
-      width: 2
-    }),
-    fill: new Fill({
-      color: "rgba(255, 150, 0, .3)"
-    })
-  }),
-  orange2: new Style({
-    stroke: new Stroke({
-      color: "rgba(255, 166, 10, .9)",
-      width: 5
-    }),
-    fill: new Fill({
-      color: "rgba(255, 166, 0, .7)"
-    })
-  }),
-  red1: new Style({
-    stroke: new Stroke({
-      color: "rgba(255, 0, 0, 1)",
-      width: 4
-    })
-  }),
-  circleBlue: new Style({
-    image: new Circle({
-      radius: 5,
-      fill: new Fill({ color: "rgba(255, 0, 0, 0.1)" }),
-      stroke: new Stroke({ color: "blue", width: 1 })
-    })
-  }),
-  textFW1: new Style({
-    text: new Text({
-      font: 'bold 12px "Open Sans", "sans-serif"',
-      placement: "line",
-      fill: new Fill({
-        color: "black"
-      }),
-      text: "LOS: 1"
-      //, zIndex: 10
-    })
-  }),
-  textFW2: new Style({
-    text: new Text({
-      font: 'bold 12px "Open Sans", "sans-serif"',
-      placement: "line",
-      fill: new Fill({
-        color: "black"
-      }),
-      text: "LOS: 2"
-      //, zIndex: 10
-    })
-  }),
-  textFW3: new Style({
-    text: new Text({
-      font: 'bold 12px "Open Sans", "sans-serif"',
-      placement: "line",
-      fill: new Fill({
-        color: "black"
-      }),
-      text: "LOS: 3"
-      //, zIndex: 10
-    })
-  })
-};
-
-const styleFactory = function(mapId) {
-  const recencyColorCodes = {
-    OK: "lime",
-    WARNING: "yellow",
-    DANGER: "red",
-    NEUTRAL: "white"
-  };
-  const gmAvailabilityColorCodes = {
-    OK: "lime",
-    WARNING: "yellow",
-    DANGER: "red",
-    NEUTRAL: "white"
-  };
-  const forecastAccuracyColorCodes = {
-    OK: "lime",
-    WARNING: "yellow",
-    DANGER: "red",
-    NEUTRAL: "white"
-  };
-
-  const forecastVsRealityColorCodes = {
-    OK: "lime",
-    WARNING: "yellow",
-    DANGER: "red",
-    NEUTRAL: "white"
-  };
-  return {
-    recencyColorCodes: recencyColorCodes,
-    gmAvailabilityColorCodes: gmAvailabilityColorCodes,
-    forecastAccuracyColorCodes: forecastAccuracyColorCodes,
-    forecastVsRealityColorCodes: forecastVsRealityColorCodes,
-    stretches(feature) {
-      let style = styles.yellow2;
-      if (feature.get("highlighted")) {
-        style = styles.yellow3;
-      }
-      return style;
-    },
-    sections(feature) {
-      let style = styles.orange1;
-      if (feature.get("highlighted")) {
-        style = styles.orange2;
-      }
-      return style;
-    },
-    fwd1() {
-      return [styles.blue1, styles.textFW1];
-    },
-    fwd2() {
-      return [styles.blue2, styles.textFW2];
-    },
-    fwd3() {
-      return [styles.blue3, styles.textFW3];
-    },
-    bottleneck() {
-      return styles.yellow1;
-    },
-    bottleneckStatus(feature, resolution, isLegend) {
-      let s = [];
-      if ((feature.get("fa_critical") && resolution > 15) || isLegend) {
-        let bnCenter = getCenter(feature.getGeometry().getExtent());
-        s.push(
-          new Style({
-            geometry: new Point(bnCenter),
-            image: new Icon({
-              src: require("@/assets/marker-bottleneck-critical.png"),
-              anchor: [0.5, 0.5],
-              scale: isLegend ? 0.5 : 1
-            })
-          })
-        );
-      }
-      if (feature.get("fa_critical") && !isLegend) {
-        s.push(styles.red1);
-      }
-      return s;
-    },
-    bottleneckFairwayAvailability(feature, resolution, isLegend) {
-      let s = [];
-      if (isLegend) {
-        s.push(
-          new Style({
-            image: new Icon({
-              src: require("@/assets/fa-diagram.png"),
-              anchor: [0.5, 0.5],
-              scale: 1
-            })
-          })
-        );
-      }
-      if (feature.get("fa_critical") && feature.get("fa_data")) {
-        let data = feature.get("fa_data");
-        const heightInPixel = 80;
-        const relativeHeightInPercent = heightInPixel / 100;
-        let lnwlHeight = relativeHeightInPercent * data.ldc;
-        let belowThresholdHeight = relativeHeightInPercent * data.below;
-        let betweenThresholdHeight = relativeHeightInPercent * data.between;
-        let aboveThresholdHeight = relativeHeightInPercent * data.above;
-        let lnwl = `<rect x='2' y='${2 +
-          heightInPixel -
-          lnwlHeight}' width='10' height='${lnwlHeight}' stroke-width='0' fill='aqua'/>`;
-        let above = `<rect x='12' y='${2 +
-          heightInPixel -
-          aboveThresholdHeight}' width='18' height='${aboveThresholdHeight}' stroke-width='0' fill='blue'/>`;
-        let between = `<rect x='12' y='${2 +
-          heightInPixel -
-          aboveThresholdHeight -
-          betweenThresholdHeight}' width='18' height='${betweenThresholdHeight}' stroke-width='0' fill='darksalmon'/>`;
-        let below = `<rect x='12' y='${2 +
-          heightInPixel -
-          aboveThresholdHeight -
-          betweenThresholdHeight -
-          belowThresholdHeight}' width='18' height='${belowThresholdHeight}' stroke-width='0' fill='hotpink'/>`;
-        let frame = `<rect x='0' y='0' width='32' height='84' stroke-width='0' fill='white'/>`;
-        let svg = `data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' width='32' height='84'><g>${frame}${lnwl}${above}${between}${below}</g></svg>`;
-        let bnCenter = getCenter(feature.getGeometry().getExtent());
-        s.push(
-          new Style({
-            geometry: new Point(bnCenter),
-            image: new Icon({
-              src: svg,
-              anchor: [1.2, 1.2]
-            })
-          })
-        );
-      }
-      return s;
-    },
-    dataAvailability(feature, resolution, isLegend) {
-      let s = [];
-      if (isLegend) {
-        s.push(
-          new Style({
-            image: new Icon({
-              src: require("@/assets/da-diagram.png"),
-              anchor: [0.5, 0.5],
-              scale: 1
-            })
-          })
-        );
-      } else {
-        // TODO: Get information from feature and check the ranges according to #423, #424, #425
-        let colorWaterlevel =
-          gmAvailabilityColorCodes[classifications.gmAvailability(feature)];
-        let colorComparison =
-          forecastVsRealityColorCodes[
-            classifications.forecastVsReality(feature)
-          ];
-        let colorAccuracy =
-          forecastAccuracyColorCodes[classifications.forecastAccuracy(feature)];
-        let map = store.getters["map/openLayersMap"](mapId);
-        let geom = feature.getGeometry();
-        if (!(geom instanceof Point)) {
-          geom = new Point(getCenter(feature.getGeometry().getExtent()));
-        }
-        if (
-          (map.getLayer("BOTTLENECKS").getVisible() &&
-            feature.getId().indexOf("bottlenecks") > -1) ||
-          (map.getLayer("SECTIONS").getVisible() &&
-            feature.getId().indexOf("sections") > -1) ||
-          (map.getLayer("STRETCHES").getVisible() &&
-            feature.getId().indexOf("stretches") > -1) ||
-          (map.getLayer("GAUGES").getVisible() &&
-            feature.getId().indexOf("gauges") > -1)
-        ) {
-          let frame = `<polyline points='16,0 32,28 0,28 16,0' stroke='grey' stroke-width='1' fill='white'/>`;
-          let waterlevel = `<polyline points="16,0 24,14 16,28 8,14 16,0" stroke='grey' stroke-width='1' fill='${colorWaterlevel}'/>`;
-          let accuracy = `<polyline points="24,14 32,28 16,28 24,14" stroke='grey' stroke-width='1' fill='${colorAccuracy}'/>`;
-          let comparison = `<polyline points="8,14 16,28 0,28 8,14" stroke='grey' stroke-width='1' fill='${colorComparison}'/>`;
-          let svg = `data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' width='32' height='28'><g>${frame}${waterlevel}${comparison}${accuracy}</g></svg>`;
-          s.push(
-            new Style({
-              geometry: geom,
-              image: new Icon({
-                src: svg,
-                anchor: [-0.5, 1]
-              })
-            })
-          );
-        }
-
-        if (
-          map.getLayer("BOTTLENECKS").getVisible() &&
-          feature.getId().indexOf("bottlenecks") > -1
-        ) {
-          let colorUniformTriangle =
-            recencyColorCodes[classifications.surveyRecency(feature)];
-          let frame = `<polyline points='16,0 32,28 0,28 16,0' stroke='grey' stroke-width='1' fill='${colorUniformTriangle}'/>`;
-          let svg = `data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' width='32' height='28'><g>${frame}</g></svg>`;
-          s.push(
-            new Style({
-              geometry: geom,
-              image: new Icon({
-                src: svg,
-                anchor: [0.5, 1]
-              })
-            })
-          );
-        }
-      }
-      return s;
-    },
-    dma(feature, resolution) {
-      if (resolution < 10) {
-        var s = styles.circleBlue;
-        if (resolution < 6) {
-          s.setText(
-            new Text({
-              offsetY: 12,
-              font: '10px "Open Sans", "sans-serif"',
-              fill: new Fill({
-                color: "black"
-              }),
-              text: (feature.get("hectometre") / 10).toString()
-            })
-          );
-        }
-        return s;
-      }
-      return [];
-    },
-    gauge(feature, resolution, isLegend) {
-      let waterlevel = feature.get("gm_waterlevel");
-      let text = feature.get("objname");
-      let iconColor = "white";
-      if (waterlevel) {
-        text += "\n(" + waterlevel + " cm)";
-        let refWaterlevels = JSON.parse(feature.get("reference_water_levels"));
-        if (refWaterlevels) {
-          const HDC =
-            refWaterlevels[
-              Object.keys(refWaterlevels).find(e => /HDC/.test(e))
-            ];
-          const LDC =
-            refWaterlevels[
-              Object.keys(refWaterlevels).find(e => /LDC/.test(e))
-            ];
-          if (waterlevel < LDC) iconColor = "brown";
-          if (waterlevel > LDC && waterlevel < HDC) iconColor = "blue";
-          if (waterlevel > HDC) iconColor = "red";
-        }
-      }
-
-      return [
-        new Style({
-          image: new Icon({
-            src: require("@/assets/marker-gauge-" + iconColor + ".png"),
-            anchor: [0.5, isLegend ? 0.5 : 1],
-            scale: isLegend ? 0.5 : 1
-          }),
-          text: new Text({
-            font: '10px "Open Sans", "sans-serif"',
-            offsetY: 15,
-            fill: new Fill({
-              color: "black"
-            }),
-            backgroundFill: new Fill({
-              color: "rgba(255, 255, 255, 0.7)"
-            }),
-            padding: [2, 2, 2, 2],
-            text
-          })
-        })
-      ];
-    }
-  };
-};
-
-export { styles, styleFactory };
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/client/src/components/toolbar/TimeSlider.vue	Fri Mar 27 15:57:40 2020 +0100
@@ -0,0 +1,92 @@
+<template>
+  <div @click="showSlider" class="toolbar-button" v-tooltip.right="label">
+    <pre
+      :class="[
+        'menuEntry',
+        {
+          'text-info': this.showTimeSlider
+        }
+      ]"
+      >{{ currentTimeSelection }}</pre
+    >
+  </div>
+</template>
+
+<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) 2020 by via donau
+ *   – Österreichische Wasserstraßen-Gesellschaft mbH
+ * Software engineering by Intevation GmbH
+ *
+ * Author(s):
+ * Fadi Abbud <fadiabbud@intevation.de>
+ */
+import { mapState } from "vuex";
+import locale2 from "locale2";
+import { format } from "date-fns";
+
+export default {
+  computed: {
+    ...mapState("application", [
+      "showTimeSlider",
+      "currentVisibleTime",
+      "storedTime"
+    ]),
+    label() {
+      const date = this.currentVisibleTime;
+      return `<b>${this.currentVisibleTime.toLocaleDateString(locale2, {
+        day: "2-digit",
+        month: "2-digit",
+        year: "numeric"
+      })} ${format(date, "HH:mm")}</b>`;
+    },
+    currentTimeSelection() {
+      const date = this.currentVisibleTime;
+      const result = date.toLocaleDateString(locale2, {
+        day: "2-digit",
+        month: "2-digit"
+      });
+      return `${format(date, "HH:mm")}\n${result}\n${date.getFullYear()}`;
+    }
+  },
+  methods: {
+    showSlider() {
+      if (this.showTimeSlider) {
+        this.$store.commit(
+          "application/setStoredTime",
+          this.currentVisibleTime
+        );
+        this.$store.commit("application/setSelectedTime", new Date());
+        this.$store.commit("application/showTimeSlider", false);
+      } else {
+        this.$store.commit("application/setSelectedTime", this.storedTime);
+        this.$store.commit("application/showTimeSlider", true);
+      }
+    }
+  }
+};
+</script>
+<style lang="scss" scoped>
+.menuEntry {
+  font-size: 9px;
+  font-weight: bold;
+  line-height: normal;
+}
+
+pre {
+  margin-top: 0px;
+  margin-bottom: 0px;
+  text-align: left;
+  font-family: sans-serif;
+}
+
+.toolbar-button {
+  height: 2.5rem;
+  width: auto;
+}
+</style>
--- a/client/src/components/toolbar/Toolbar.vue	Mon Mar 23 15:29:55 2020 +0100
+++ b/client/src/components/toolbar/Toolbar.vue	Fri Mar 27 15:57:40 2020 +0100
@@ -7,6 +7,7 @@
       "
     >
       <Identify />
+      <TimeSlider />
       <Layers />
       <Profiles />
       <Gauges />
@@ -128,7 +129,8 @@
     Profiles: () => import("./Profiles"),
     Gauges: () => import("./Gauges"),
     Pdftool: () => import("./Pdftool"),
-    AvailableFairwayDepth: () => import("./AvailableFairwayDepth")
+    AvailableFairwayDepth: () => import("./AvailableFairwayDepth"),
+    TimeSlider: () => import("./TimeSlider")
   },
   computed: {
     ...mapState("application", ["expandToolbar"])
--- a/client/src/lib/session.js	Mon Mar 23 15:29:55 2020 +0100
+++ b/client/src/lib/session.js	Fri Mar 27 15:57:40 2020 +0100
@@ -12,10 +12,10 @@
  * Thomas Junk <thomas.junk@intevation.de>
  */
 
+import { HTTP } from "@/lib/http";
 import app from "@/main";
-import { unsetLayerConfigs } from "@/components/map/layers";
-import { HTTP } from "@/lib/http";
 import { displayError } from "@/lib/errors";
+import { unsetLayerConfigs } from "@/components/layers/layers";
 
 const logOff = () => {
   const hasToken = localStorage.getItem("token");
--- a/client/src/store/application.js	Mon Mar 23 15:29:55 2020 +0100
+++ b/client/src/store/application.js	Fri Mar 27 15:57:40 2020 +0100
@@ -14,8 +14,9 @@
  *   Bernhard E. Reiter <bernhard.reiter@intevation.de>
  */
 
+import { displayError, displayInfo } from "@/lib/errors";
+
 import { HTTP } from "@/lib/http";
-import { displayError, displayInfo } from "@/lib/errors";
 import { version } from "../../package.json";
 
 // initial state
@@ -41,10 +42,15 @@
     showGauges: false,
     showFairwayDepth: false,
     showFairwayDepthLNWL: false,
+    showTimeSlider: false,
     contextBoxContent: null, // bottlenecks, imports, staging
     expandToolbar: true,
     countries: ["AT", "SK", "HU", "HR", "RS", "BG", "RO"],
     searchQuery: "",
+    selectedTime: new Date(),
+    currentVisibleTime: new Date(),
+    refreshLayersTime: new Date(),
+    storedTime: new Date(),
     version,
     tempRoute: "",
     config: {}
@@ -76,6 +82,15 @@
     }
   },
   mutations: {
+    setCurrentVisibleTime: (state, currentVisibleTime) => {
+      state.currentVisibleTime = currentVisibleTime;
+    },
+    setLayerRefreshedTime: (state, refreshLayersTime) => {
+      state.refreshLayersTime = refreshLayersTime;
+    },
+    setStoredTime: (state, storedTime) => {
+      state.storedTime = storedTime;
+    },
     setTempRoute: (state, tempRoute) => {
       state.tempRoute = tempRoute;
     },
@@ -93,6 +108,12 @@
         if (state.paneRotate === 5) state.paneRotate = 1;
       }
     },
+    setSelectedTime: (state, time) => {
+      state.selectedTime = time;
+    },
+    showTimeSlider: (state, show) => {
+      state.showTimeSlider = show;
+    },
     showSidebar: (state, show) => {
       state.showSidebar = show;
     },
--- a/client/src/store/map.js	Mon Mar 23 15:29:55 2020 +0100
+++ b/client/src/store/map.js	Fri Mar 27 15:57:40 2020 +0100
@@ -53,6 +53,7 @@
     isolinesLegendImgDataURL: "",
     differencesLegendImgDataURL: "",
     ongoingRefresh: false,
+    ongoingTimeSlide: false,
     reviewActive: false
   };
 };
@@ -102,6 +103,12 @@
     finishRefreshLayers: state => {
       state.ongoingRefresh = false;
     },
+    startTimeSlide: state => {
+      state.ongoingTimeSlide = true;
+    },
+    finishTimeSlide: state => {
+      state.ongoingTimeSlide = false;
+    },
     initialLoad: (state, initialLoad) => {
       state.initialLoad = initialLoad;
     },
@@ -579,7 +586,10 @@
           event.coordinate,
           currentResolution,
           "EPSG:3857",
-          { INFO_FORMAT: "application/json" }
+          {
+            INFO_FORMAT: "application/json",
+            TIME: rootState.application.currentVisibleTime.toISOString()
+          }
         );
         if (fmSource) {
           HTTP.get(fmURL, {
@@ -612,6 +622,31 @@
         }
       });
     },
+    refreshTimebasedLayers({ state, rootState }) {
+      const layers = [
+        "BOTTLENECKS",
+        "GAUGES",
+        "FAIRWAYDIMENSIONSLOS1",
+        "FAIRWAYDIMENSIONSLOS2",
+        "FAIRWAYDIMENSIONSLOS3",
+        "WATERWAYAXIS",
+        "FAIRWAYMARKS"
+      ];
+      state.openLayersMaps.forEach(map => {
+        for (let i = 0; i < layers.length; i++) {
+          let layer = map.getLayer(layers[i]);
+          if (layer instanceof VectorLayer) {
+            layer.getSource().clear(true);
+          } else {
+            // Refresh layers with updated TIME value
+            layer.getSource().updateParams({
+              TIME: rootState.application.refreshLayersTime.toISOString()
+            });
+          }
+          layer.getSource().refresh();
+        }
+      });
+    },
     moveToBoundingBox(
       { state },
       { boundingBox, zoom, preventZoomOut, duration }
--- a/pkg/controllers/importqueue.go	Mon Mar 23 15:29:55 2020 +0100
+++ b/pkg/controllers/importqueue.go	Fri Mar 27 15:57:40 2020 +0100
@@ -23,6 +23,7 @@
 	"net/http"
 	"strconv"
 	"strings"
+	"sync"
 	"time"
 
 	"github.com/gorilla/mux"
@@ -120,6 +121,9 @@
 		b = append(b, term)
 	}
 
+	// Always filter review jobs. They are only for internal use.
+	cond(` NOT kind LIKE '%%` + imports.ReviewJobSuffix + `'`)
+
 	if query := req.FormValue("query"); query != "" {
 		query = "%" + query + "%"
 		cond(` (kind ILIKE $%d OR username ILIKE $%d OR signer ILIKE $%d OR `+
@@ -595,21 +599,46 @@
 
 	results := make([]reviewResult, len(rs))
 
-	for i := range rs {
-		rev := &rs[i]
-		msg, err := decideImport(req, rev.ID, string(rev.State))
-		var errString string
-		if err != nil {
-			errString = err.Error()
-		}
-		results[i] = reviewResult{
-			ID:      rev.ID,
-			Message: msg,
-			Error:   errString,
-		}
+	for i := range results {
+		results[i].ID = rs[i].ID
+		results[i].Message = fmt.Sprintf("Finalizing import #%d in progress.", rs[i].ID)
 	}
 
-	return mw.JSONResult{Result: results}, nil
+	var wg sync.WaitGroup
+	var mu sync.Mutex
+
+	for i := range rs {
+		wg.Add(1)
+		go func(idx int) {
+			defer wg.Done()
+			rev := &rs[idx]
+			msg, err := decideImport(req, rev.ID, string(rev.State))
+			mu.Lock()
+			if err != nil {
+				results[idx].Error = err.Error()
+			}
+			results[idx].Message = msg
+			mu.Unlock()
+		}(i)
+	}
+
+	done := make(chan struct{})
+	go func() {
+		defer close(done)
+		wg.Wait()
+	}()
+
+	select {
+	case <-time.After(5 * time.Second):
+	case <-done:
+	}
+
+	out := make([]reviewResult, len(rs))
+	mu.Lock()
+	copy(out, results)
+	mu.Unlock()
+
+	return mw.JSONResult{Result: out}, nil
 }
 
 func reviewImport(req *http.Request) (jr mw.JSONResult, err error) {
@@ -638,23 +667,16 @@
 	id int64,
 	state string,
 ) (message string, err error) {
-	ctx := req.Context()
-
-	accepted := state == "accepted"
 
 	session, _ := auth.GetSession(req)
 	reviewer := session.User
 
+	ctx := req.Context()
+	accepted := state == "accepted"
+
 	if err = imports.DecideImport(ctx, id, accepted, reviewer); err != nil {
-		err = mw.JSONError{
-			Code:    http.StatusBadRequest,
-			Message: err.Error(),
-		}
-		return
+		return "", err
 	}
 
-	message = fmt.Sprintf(
-		"Import #%d successfully changed to state '%s'.", id, state)
-
-	return
+	return fmt.Sprintf("Import #%d is %s.", id, state), nil
 }
--- a/pkg/controllers/routes.go	Mon Mar 23 15:29:55 2020 +0100
+++ b/pkg/controllers/routes.go	Fri Mar 27 15:57:40 2020 +0100
@@ -176,11 +176,6 @@
 		Handle: listSurveys,
 	})).Methods(http.MethodGet)
 
-	// Bottlenecks
-	api.Handle("/bottlenecks", any(&mw.JSONHandler{
-		Handle: listBottlenecks,
-	})).Methods(http.MethodGet)
-
 	// difference calculation
 	api.Handle("/diff", any(&mw.JSONHandler{
 		Input:  func(*http.Request) interface{} { return new(models.DiffCalculationInput) },
--- a/pkg/controllers/search.go	Mon Mar 23 15:29:55 2020 +0100
+++ b/pkg/controllers/search.go	Fri Mar 27 15:57:40 2020 +0100
@@ -4,7 +4,7 @@
 // SPDX-License-Identifier: AGPL-3.0-or-later
 // License-Filename: LICENSES/AGPL-3.0.txt
 //
-// Copyright (C) 2018 by via donau
+// Copyright (C) 2018, 2020 by via donau
 //   – Österreichische Wasserstraßen-Gesellschaft mbH
 // Software engineering by Intevation GmbH
 //
@@ -15,7 +15,6 @@
 package controllers
 
 import (
-	"database/sql"
 	"net/http"
 	"strings"
 
@@ -25,19 +24,7 @@
 )
 
 const (
-	searchMostSQL = `SELECT search_most($1)::text`
-
-	listBottlenecksSQL = `
-SELECT COALESCE(json_agg(r),'[]')
-FROM (
-  SELECT
-    objnam AS name,
-    ST_AsGeoJSON(ST_Centroid(area))::json AS geom,
-    'bottleneck' AS type
-  FROM waterway.bottlenecks
-  WHERE validity @> current_timestamp
-ORDER BY objnam) r
-`
+	searchMostSQL = `SELECT search_most($1,$2)::text`
 )
 
 func searchFeature(req *http.Request) (jr mw.JSONResult, err error) {
@@ -57,6 +44,7 @@
 		req.Context(),
 		searchMostSQL,
 		s.SearchString,
+		s.SearchTime,
 	).Scan(&result)
 
 	if err != nil {
@@ -66,24 +54,3 @@
 	jr.Result = strings.NewReader(result)
 	return
 }
-
-func listBottlenecks(req *http.Request) (jr mw.JSONResult, err error) {
-
-	var result string
-	err = mw.JSONConn(req).QueryRowContext(
-		req.Context(), listBottlenecksSQL).Scan(&result)
-
-	switch {
-	case err == sql.ErrNoRows:
-		err = mw.JSONError{
-			Code:    http.StatusNotFound,
-			Message: "Cannot find any bottleneck.",
-		}
-		return
-	case err != nil:
-		return
-	}
-
-	jr = mw.JSONResult{Result: strings.NewReader(result)}
-	return
-}
--- a/pkg/imports/dsr.go	Mon Mar 23 15:29:55 2020 +0100
+++ b/pkg/imports/dsr.go	Fri Mar 27 15:57:40 2020 +0100
@@ -112,8 +112,13 @@
 		dsr.BottleneckID, dsr.Date.Time)
 
 	var id int64
-	if err := tx.QueryRowContext(ctx, dsrFindSQL,
-		dsr.BottleneckID, dsr.Date.Time).Scan(&id); err != nil {
+	switch err := tx.QueryRowContext(ctx, dsrFindSQL,
+		dsr.BottleneckID, dsr.Date.Time).Scan(&id); err {
+	case sql.ErrNoRows:
+		return nil, UnchangedError("Sounding result does not exist")
+	case nil:
+		// Continue
+	default:
 		return nil, err
 	}
 
--- a/pkg/imports/queue.go	Mon Mar 23 15:29:55 2020 +0100
+++ b/pkg/imports/queue.go	Fri Mar 27 15:57:40 2020 +0100
@@ -66,6 +66,11 @@
 		CleanUp() error
 	}
 
+	FeedbackJob interface {
+		Job
+		CreateFeedback(int64) Feedback
+	}
+
 	// JobKind is the type of an import.
 	// Choose a unique name for every import.
 	JobKind string
@@ -92,12 +97,17 @@
 		AutoAccept() bool
 	}
 
+	JobRemover interface {
+		JobCreator
+		RemoveJob() bool
+	}
+
 	idJob struct {
 		id        int64
 		kind      JobKind
 		user      string
 		waitRetry pgtype.Interval
-		trysLeft  sql.NullInt64
+		triesLeft sql.NullInt64
 		sendEmail bool
 		data      string
 	}
@@ -108,17 +118,28 @@
 	runExclusive = -66666
 )
 
+const (
+	ReviewJobSuffix  = "#review"
+	reviewJobRetries = 10
+	reviewJobWait    = time.Minute
+)
+
 type importQueue struct {
-	signalChan chan struct{}
+	cmdCh chan func(*importQueue)
+
 	creatorsMu sync.Mutex
 	creators   map[JobKind]JobCreator
 	usedDeps   map[string]int
+
+	waiting map[int64]chan struct{}
 }
 
 var iqueue = importQueue{
-	signalChan: make(chan struct{}),
-	creators:   map[JobKind]JobCreator{},
-	usedDeps:   map[string]int{},
+	cmdCh: make(chan func(*importQueue)),
+
+	creators: map[JobKind]JobCreator{},
+	usedDeps: map[string]int{},
+	waiting:  make(map[int64]chan struct{}),
 }
 
 var (
@@ -131,6 +152,7 @@
 		"pending",
 		"accepted",
 		"declined",
+		"reviewed",
 	}
 )
 
@@ -162,8 +184,9 @@
   $7
 ) RETURNING id`
 
+	// Select oldest queued job but prioritize review jobs
 	selectJobSQL = `
-SELECT
+SELECT DISTINCT ON (kind LIKE '%` + ReviewJobSuffix + `')
   id,
   kind,
   trys_left,
@@ -174,11 +197,9 @@
 FROM import.imports
 WHERE
   due <= CURRENT_TIMESTAMP + interval '5 seconds' AND
-  state = 'queued'::import_state AND enqueued IN (
-    SELECT min(enqueued)
-    FROM import.imports
-    WHERE state = 'queued'::import_state AND
-    kind = ANY($1))
+  state = 'queued'::import_state AND
+  kind = ANY($1)
+ORDER BY kind LIKE '%` + ReviewJobSuffix + `' DESC, enqueued
 LIMIT 1`
 
 	updateStateSQL = `
@@ -194,6 +215,9 @@
    summary = $2
 WHERE id = $3`
 
+	deleteJobSQL = `
+DELETE FROM import.imports WHERE id = $1`
+
 	logMessageSQL = `
 INSERT INTO import.import_logs (
   import_id,
@@ -215,10 +239,122 @@
 	return string(ue)
 }
 
+type reviewedJobCreator struct {
+	jobCreator JobCreator
+}
+
+func (*reviewedJobCreator) AutoAccept() bool {
+	return true
+}
+
+func (*reviewedJobCreator) RemoveJob() bool {
+	return true
+}
+
+func (rjc *reviewedJobCreator) Depends() [2][]string {
+	return rjc.jobCreator.Depends()
+}
+
+func (rjc *reviewedJobCreator) Description() string {
+	return rjc.jobCreator.Description() + ReviewJobSuffix
+}
+
+func (*reviewedJobCreator) StageDone(context.Context, *sql.Tx, int64, Feedback) error {
+	return nil
+}
+
+type reviewedJob struct {
+	ID       int64 `json:"id"`
+	Accepted bool  `json:"accepted"`
+}
+
+func (*reviewedJobCreator) Create() Job {
+	return new(reviewedJob)
+}
+
+func (*reviewedJob) CleanUp() error { return nil }
+
+func (r *reviewedJob) CreateFeedback(int64) Feedback {
+	return logFeedback(r.ID)
+}
+
+func (rj *reviewedJob) Do(
+	ctx context.Context,
+	importID int64,
+	conn *sql.Conn,
+	feedback Feedback,
+) (interface{}, error) {
+
+	tx, err := conn.BeginTx(ctx, nil)
+	if err != nil {
+		return nil, err
+	}
+	defer tx.Rollback()
+
+	var signer string
+	if err := tx.QueryRowContext(ctx, selectUserSQL, importID).Scan(&signer); err != nil {
+		return nil, err
+	}
+
+	var user, kind string
+	if err := tx.QueryRowContext(ctx, selectUserKindSQL, rj.ID).Scan(&user, &kind); err != nil {
+		return nil, err
+	}
+
+	jc := FindJobCreator(JobKind(kind))
+	if jc == nil {
+		return nil, fmt.Errorf("no job creator found for '%s'", kind)
+	}
+
+	importFeedback := logFeedback(rj.ID)
+
+	if err := auth.RunAs(ctx, user, func(conn *sql.Conn) error {
+		userTx, err := conn.BeginTx(ctx, nil)
+		if err != nil {
+			return err
+		}
+		defer userTx.Rollback()
+
+		if rj.Accepted {
+			err = jc.StageDone(ctx, userTx, rj.ID, importFeedback)
+		} else {
+			_, err = userTx.ExecContext(ctx, deleteImportDataSQL, rj.ID)
+		}
+		if err == nil {
+			err = userTx.Commit()
+		}
+		return err
+	}); err != nil {
+		return nil, err
+	}
+
+	// Remove the import track
+	if _, err := tx.ExecContext(ctx, deleteImportTrackSQL, rj.ID); err != nil {
+		return nil, err
+	}
+
+	var state string
+	if rj.Accepted {
+		state = "accepted"
+	} else {
+		state = "declined"
+	}
+
+	if _, err := tx.ExecContext(ctx, reviewSQL, state, signer, rj.ID); err != nil {
+		return nil, err
+	}
+
+	importFeedback.Info("User '%s' %s import %d.", signer, state, rj.ID)
+
+	return nil, tx.Commit()
+}
+
 func (q *importQueue) registerJobCreator(kind JobKind, jc JobCreator) {
 	q.creatorsMu.Lock()
 	defer q.creatorsMu.Unlock()
 	q.creators[kind] = jc
+	q.creators[kind+ReviewJobSuffix] = &reviewedJobCreator{jobCreator: jc}
+
 }
 
 // FindJobCreator looks up a JobCreator in the global import queue.
@@ -275,16 +411,16 @@
 
 func (idj *idJob) nextRetry(feedback Feedback) bool {
 	switch {
-	case idj.waitRetry.Status != pgtype.Present && !idj.trysLeft.Valid:
+	case idj.waitRetry.Status != pgtype.Present && !idj.triesLeft.Valid:
 		return false
-	case idj.waitRetry.Status == pgtype.Present && !idj.trysLeft.Valid:
+	case idj.waitRetry.Status == pgtype.Present && !idj.triesLeft.Valid:
 		return true
-	case idj.trysLeft.Valid:
-		if idj.trysLeft.Int64 < 1 {
-			feedback.Warn("import should be retried, but no retrys left")
+	case idj.triesLeft.Valid:
+		if idj.triesLeft.Int64 < 1 {
+			feedback.Warn("no retries left")
 		} else {
-			idj.trysLeft.Int64--
-			feedback.Info("import failed but will be retried")
+			idj.triesLeft.Int64--
+			feedback.Info("failed but will retry")
 			return true
 		}
 	}
@@ -304,11 +440,11 @@
 	return now
 }
 
-func (idj *idJob) trysLeftPointer() *int {
-	if !idj.trysLeft.Valid {
+func (idj *idJob) triesLeftPointer() *int {
+	if !idj.triesLeft.Valid {
 		return nil
 	}
-	t := int(idj.trysLeft.Int64)
+	t := int(idj.triesLeft.Int64)
 	return &t
 }
 
@@ -357,12 +493,13 @@
 func (q *importQueue) addJob(
 	kind JobKind,
 	due time.Time,
-	trysLeft *int,
+	triesLeft *int,
 	waitRetry *time.Duration,
 	user string,
 	sendEmail bool,
 	data string,
-) (int64, error) {
+	sync bool,
+) (int64, chan struct{}, error) {
 
 	var id int64
 	if due.IsZero() {
@@ -371,39 +508,47 @@
 	due = due.UTC()
 
 	var tl sql.NullInt64
-	if trysLeft != nil {
-		tl = sql.NullInt64{Int64: int64(*trysLeft), Valid: true}
+	if triesLeft != nil {
+		tl = sql.NullInt64{Int64: int64(*triesLeft), Valid: true}
 	}
 
 	var wr pgtype.Interval
 	if waitRetry != nil {
 		if err := wr.Set(*waitRetry); err != nil {
-			return 0, err
+			return 0, nil, err
 		}
 	} else {
 		wr = pgtype.Interval{Status: pgtype.Null}
 	}
 
-	ctx := context.Background()
-	err := auth.RunAs(ctx, user, func(conn *sql.Conn) error {
-		return conn.QueryRowContext(
-			ctx,
-			insertJobSQL,
-			string(kind),
-			due,
-			tl,
-			&wr,
-			user,
-			sendEmail,
-			data).Scan(&id)
-	})
-	if err == nil {
-		select {
-		case q.signalChan <- struct{}{}:
-		default:
-		}
+	errCh := make(chan error)
+	var done chan struct{}
+
+	q.cmdCh <- func(q *importQueue) {
+		ctx := context.Background()
+		errCh <- auth.RunAs(ctx, user, func(conn *sql.Conn) error {
+			err := conn.QueryRowContext(
+				ctx,
+				insertJobSQL,
+				string(kind),
+				due,
+				tl,
+				&wr,
+				user,
+				sendEmail,
+				data).Scan(&id)
+
+			if err == nil && sync {
+				log.Printf("info: register wait for %d\n", id)
+				done = make(chan struct{})
+				q.waiting[id] = done
+			}
+
+			return err
+		})
 	}
-	return id, err
+
+	return id, done, <-errCh
 }
 
 // AddJob adds a job to the global import queue to be executed
@@ -413,31 +558,38 @@
 func AddJob(
 	kind JobKind,
 	due time.Time,
-	trysLeft *int,
+	triesLeft *int,
 	waitRetry *time.Duration,
 	user string,
 	sendEmail bool,
 	data string,
 ) (int64, error) {
-	return iqueue.addJob(
+	id, _, err := iqueue.addJob(
 		kind,
 		due,
-		trysLeft,
+		triesLeft,
 		waitRetry,
 		user,
 		sendEmail,
-		data)
+		data,
+		false)
+	return id, err
 }
 
 const (
 	isPendingSQL = `
 SELECT
 	state = 'pending'::import_state,
-	kind,
-	username
+	kind
 FROM import.imports
 WHERE id = $1`
 
+	selectUserSQL = `
+SELECT username from import.imports WHERE ID = $1`
+
+	selectUserKindSQL = `
+SELECT username, kind from import.imports WHERE ID = $1`
+
 	reviewSQL = `
 UPDATE import.imports SET
   state = $1::import_state,
@@ -449,9 +601,6 @@
 
 	deleteImportTrackSQL = `
 DELETE FROM import.track_imports WHERE import_id = $1`
-
-	logDecisionSQL = `
-INSERT INTO import.import_logs (import_id, msg) VALUES ($1, $2)`
 )
 
 func (q *importQueue) decideImportTx(
@@ -460,70 +609,62 @@
 	id int64,
 	accepted bool,
 	reviewer string,
-) error {
+) (chan struct{}, error) {
 	var (
 		pending bool
 		kind    string
-		user    string
 	)
 
-	switch err := tx.QueryRowContext(ctx, isPendingSQL, id).Scan(&pending, &kind, &user); {
+	switch err := tx.QueryRowContext(ctx, isPendingSQL, id).Scan(&pending, &kind); {
 	case err == sql.ErrNoRows:
-		return fmt.Errorf("cannot find import #%d", id)
+		return nil, fmt.Errorf("cannot find import #%d", id)
 	case err != nil:
-		return err
+		return nil, err
 	case !pending:
-		return fmt.Errorf("#%d is not pending", id)
+		return nil, fmt.Errorf("#%d is not pending", id)
 	}
 
 	jc := q.jobCreator(JobKind(kind))
 	if jc == nil {
-		return fmt.Errorf("no job creator for kind '%s'", kind)
+		return nil, fmt.Errorf("no job creator for kind '%s'", kind)
+	}
+
+	r := &reviewedJob{
+		ID:       id,
+		Accepted: accepted,
+	}
+	serialized, err := common.ToJSONString(r)
+	if err != nil {
+		return nil, err
 	}
 
-	if err := auth.RunAs(ctx, user, func(conn *sql.Conn) error {
-		txUser, err := conn.BeginTx(ctx, nil)
-		if err != nil {
-			return err
-		}
-		defer txUser.Rollback()
+	// Try a little harder to persist the decision.
+	tries := reviewJobRetries
+	wait := reviewJobWait
 
-		if accepted {
-			feedback := logFeedback(id)
-			err = jc.StageDone(ctx, txUser, id, feedback)
-		} else {
-			_, err = txUser.ExecContext(ctx, deleteImportDataSQL, id)
-		}
-
-		if err == nil {
-			err = txUser.Commit()
-		}
-
-		return err
-	}); err != nil {
-		return err
+	rID, done, err := q.addJob(
+		JobKind(kind+ReviewJobSuffix),
+		time.Now(),
+		&tries,
+		&wait,
+		reviewer,
+		false,
+		serialized,
+		true)
+	if err != nil {
+		return nil, err
 	}
-
-	// Remove the import track
-	if _, err := tx.ExecContext(ctx, deleteImportTrackSQL, id); err != nil {
-		return err
+	log.Printf("info: add review job %d\n", rID)
+	_, err = tx.ExecContext(ctx, updateStateSQL, "reviewed", id)
+	if err != nil && done != nil {
+		go func() {
+			q.cmdCh <- func(q *importQueue) {
+				delete(q.waiting, rID)
+			}
+		}()
+		done = nil
 	}
-
-	var state string
-	if accepted {
-		state = "accepted"
-	} else {
-		state = "declined"
-	}
-
-	logMsg := fmt.Sprintf("User '%s' %s import %d.", reviewer, state, id)
-
-	if _, err := tx.ExecContext(ctx, logDecisionSQL, id, logMsg); err != nil {
-		return err
-	}
-
-	_, err := tx.ExecContext(ctx, reviewSQL, state, reviewer, id)
-	return err
+	return done, err
 }
 
 func (q *importQueue) decideImport(
@@ -536,18 +677,25 @@
 		ctx = context.Background()
 	}
 
-	return auth.RunAs(ctx, reviewer, func(conn *sql.Conn) error {
+	var done chan struct{}
+
+	if err := auth.RunAs(ctx, reviewer, func(conn *sql.Conn) error {
 		tx, err := conn.BeginTx(ctx, nil)
 		if err != nil {
 			return err
 		}
 		defer tx.Rollback()
-		err = q.decideImportTx(ctx, tx, id, accepted, reviewer)
+		done, err = q.decideImportTx(ctx, tx, id, accepted, reviewer)
 		if err == nil {
 			err = tx.Commit()
 		}
 		return err
-	})
+	}); err != nil {
+		return err
+	}
+
+	<-done
+	return nil
 }
 
 func DecideImport(
@@ -646,7 +794,7 @@
 		if err = tx.QueryRowContext(ctx, selectJobSQL, &kinds).Scan(
 			&ji.id,
 			&ji.kind,
-			&ji.trysLeft,
+			&ji.triesLeft,
 			&ji.waitRetry,
 			&ji.user,
 			&ji.sendEmail,
@@ -714,6 +862,13 @@
 	})
 }
 
+func deleteJob(ctx context.Context, id int64) error {
+	return tryHardToStoreState(ctx, func(conn *sql.Conn) error {
+		_, err := conn.ExecContext(ctx, deleteJobSQL, id)
+		return err
+	})
+}
+
 func errorAndFail(id int64, format string, args ...interface{}) error {
 	ctx := context.Background()
 	return tryHardToStoreState(ctx, func(conn *sql.Conn) error {
@@ -756,7 +911,9 @@
 				break
 			}
 			select {
-			case <-q.signalChan:
+			case cmd := <-q.cmdCh:
+				cmd(q)
+
 			case <-time.After(pollDuration):
 			}
 		}
@@ -774,12 +931,16 @@
 
 		go func(jc JobCreator, idj *idJob) {
 
-			// Unlock the dependencies.
 			defer func() {
+				// Unlock the dependencies.
 				q.unlockDependencies(jc)
-				select {
-				case q.signalChan <- struct{}{}:
-				default:
+				// Unlock waiting.
+				q.cmdCh <- func(q *importQueue) {
+					if w := q.waiting[idj.id]; w != nil {
+						log.Printf("info: unlock waiting %d\n", idj.id)
+						close(w)
+						delete(q.waiting, idj.id)
+					}
 				}
 			}()
 
@@ -790,9 +951,12 @@
 				return
 			}
 
-			feedback := logFeedback(idj.id)
-
-			feedback.Info("import #%d started", idj.id)
+			var feedback Feedback
+			if fc, ok := job.(FeedbackJob); ok {
+				feedback = fc.CreateFeedback(idj.id)
+			} else {
+				feedback = logFeedback(idj.id)
+			}
 
 			ctx := context.Background()
 			var summary interface{}
@@ -823,6 +987,11 @@
 				}
 			}
 
+			var remove bool
+			if remover, ok := jc.(JobRemover); ok {
+				remove = remover.RemoveJob()
+			}
+
 			var state string
 			switch {
 			case unchanged:
@@ -834,28 +1003,36 @@
 			default:
 				state = "pending"
 			}
-			if err := updateStateSummary(ctx, idj.id, state, summary); err != nil {
-				log.Printf("error: setting state of job %d failed: %v\n", idj.id, err)
+			if !remove {
+				if err := updateStateSummary(ctx, idj.id, state, summary); err != nil {
+					log.Printf("error: setting state of job %d failed: %v\n", idj.id, err)
+				}
+				log.Printf("info: import #%d finished: %s\n", idj.id, state)
 			}
-			log.Printf("info: import #%d finished: %s\n", idj.id, state)
 			if idj.sendEmail {
 				go sendNotificationMail(idj.user, jc.Description(), state, idj.id)
 			}
 
 			if retry {
-				nid, err := q.addJob(
+				nid, _, err := q.addJob(
 					idj.kind,
 					idj.nextDue(),
-					idj.trysLeftPointer(),
+					idj.triesLeftPointer(),
 					idj.waitRetryPointer(),
 					idj.user, idj.sendEmail,
-					idj.data)
+					idj.data,
+					false)
 				if err != nil {
 					log.Printf("error: retry enqueue failed: %v\n", err)
 				} else {
 					log.Printf("info: re-enqueued job with id %d\n", nid)
 				}
 			}
+			if remove {
+				if err := deleteJob(ctx, idj.id); err != nil {
+					log.Printf("error: deleting job %d failed: %v\n", idj.id, err)
+				}
+			}
 		}(jc, idj)
 	}
 }
--- a/pkg/models/search.go	Mon Mar 23 15:29:55 2020 +0100
+++ b/pkg/models/search.go	Fri Mar 27 15:57:40 2020 +0100
@@ -4,7 +4,7 @@
 // SPDX-License-Identifier: AGPL-3.0-or-later
 // License-Filename: LICENSES/AGPL-3.0.txt
 //
-// Copyright (C) 2018 by via donau
+// Copyright (C) 2018, 2020 by via donau
 //   – Österreichische Wasserstraßen-Gesellschaft mbH
 // Software engineering by Intevation GmbH
 //
@@ -13,8 +13,13 @@
 
 package models
 
+import (
+	"time"
+)
+
 type (
 	SearchRequest struct {
 		SearchString string `json:"string"`
+		SearchTime *time.Time `json:"time"`
 	}
 )
--- a/pkg/pgxutils/errors.go	Mon Mar 23 15:29:55 2020 +0100
+++ b/pkg/pgxutils/errors.go	Fri Mar 27 15:57:40 2020 +0100
@@ -132,6 +132,13 @@
 					c = http.StatusConflict
 					return
 				}
+			case "fairway_dimensions":
+				switch err.ConstraintName {
+				case "fairway_dimensions_area_unique":
+					m = "Duplicate fairway area"
+					c = http.StatusConflict
+					return
+				}
 			}
 		}
 	case exclusionViolation:
--- a/schema/auth.sql	Mon Mar 23 15:29:55 2020 +0100
+++ b/schema/auth.sql	Fri Mar 27 15:57:40 2020 +0100
@@ -61,6 +61,7 @@
 GRANT INSERT, UPDATE ON sys_admin.system_config TO sys_admin;
 GRANT UPDATE ON sys_admin.published_services TO sys_admin;
 GRANT INSERT, DELETE, UPDATE ON sys_admin.password_reset_requests TO sys_admin;
+GRANT DELETE ON import.imports, import.import_logs TO sys_admin;
 
 --
 -- Privileges assigned directly to metamorph
--- a/schema/default_sysconfig.sql	Mon Mar 23 15:29:55 2020 +0100
+++ b/schema/default_sysconfig.sql	Fri Mar 27 15:57:40 2020 +0100
@@ -218,49 +218,6 @@
         FROM waterway.sounding_results_iso_areas ia
             JOIN waterway.sounding_results sr ON sr.id = ia.sounding_result_id
     $$),
-    ('waterway', 'bottlenecks_geoserver', 4326, 'id', $$
-        SELECT
-            b.id,
-            b.bottleneck_id,
-            b.objnam,
-            b.nobjnm,
-            b.area,
-            b.rb,
-            b.lb,
-            b.responsible_country,
-            b.revisiting_time,
-            b.limiting,
-            b.date_info,
-            b.source_organization,
-            g.objname AS gauge_objname,
-            g.reference_water_levels,
-            fal.date_info AS fa_date_info,
-            fal.critical AS fa_critical,
-            g.gm_measuredate,
-            g.gm_waterlevel,
-            g.gm_n_14d,
-            srl.date_max,
-            g.forecast_accuracy_3d,
-            g.forecast_accuracy_1d
-        FROM waterway.bottlenecks b
-            LEFT JOIN (
-    $$ || (SELECT def FROM base_views WHERE name = 'gauges_base_view') || $$
-            ) AS g
-                ON b.gauge_location = g.location
-                    AND g.validity @> current_timestamp
-            LEFT JOIN (SELECT DISTINCT ON (bottleneck_id)
-                        bottleneck_id, date_info, critical
-                    FROM waterway.fairway_availability
-                    ORDER BY bottleneck_id, date_info DESC) AS fal
-                ON b.bottleneck_id = fal.bottleneck_id
-            LEFT JOIN (SELECT DISTINCT ON (bottleneck_id)
-                        bottleneck_id, max(date_info) AS date_max
-                    FROM waterway.sounding_results
-                    GROUP BY bottleneck_id
-                    ORDER BY bottleneck_id DESC) AS srl
-                ON b.bottleneck_id = srl.bottleneck_id
-        WHERE b.validity @> current_timestamp
-    $$),
     ('waterway', 'bottleneck_overview', 4326, NULL, $$
         SELECT
             objnam AS name,
@@ -304,6 +261,51 @@
     wmst_attribute, wmst_end_attribute,
     view_def
 ) VALUES
+    ('waterway', 'bottlenecks_geoserver', 4326, 'id',
+        'valid_from', 'valid_to', $$
+        SELECT
+            b.id,
+            lower(b.validity) AS valid_from,
+            COALESCE(upper(b.validity), current_timestamp) AS valid_to,
+            b.bottleneck_id,
+            b.objnam,
+            b.nobjnm,
+            b.area,
+            b.rb,
+            b.lb,
+            b.responsible_country,
+            b.revisiting_time,
+            b.limiting,
+            b.date_info,
+            b.source_organization,
+            g.objname AS gauge_objname,
+            g.reference_water_levels,
+            fal.date_info AS fa_date_info,
+            fal.critical AS fa_critical,
+            g.gm_measuredate,
+            g.gm_waterlevel,
+            g.gm_n_14d,
+            srl.date_max,
+            g.forecast_accuracy_3d,
+            g.forecast_accuracy_1d
+        FROM waterway.bottlenecks b
+            LEFT JOIN (
+    $$ || (SELECT def FROM base_views WHERE name = 'gauges_base_view') || $$
+            ) AS g
+                ON b.gauge_location = g.location
+                    AND g.validity @> current_timestamp
+            LEFT JOIN (SELECT DISTINCT ON (bottleneck_id)
+                        bottleneck_id, date_info, critical
+                    FROM waterway.fairway_availability
+                    ORDER BY bottleneck_id, date_info DESC) AS fal
+                ON b.bottleneck_id = fal.bottleneck_id
+            LEFT JOIN (SELECT DISTINCT ON (bottleneck_id)
+                        bottleneck_id, max(date_info) AS date_max
+                    FROM waterway.sounding_results
+                    GROUP BY bottleneck_id
+                    ORDER BY bottleneck_id DESC) AS srl
+                ON b.bottleneck_id = srl.bottleneck_id
+    $$),
     ('waterway', 'waterway_axis', 4326, 'id',
         'valid_from', 'valid_to', $$
         SELECT id,
--- a/schema/gemma.sql	Mon Mar 23 15:29:55 2020 +0100
+++ b/schema/gemma.sql	Fri Mar 27 15:57:40 2020 +0100
@@ -721,6 +721,9 @@
     CREATE TRIGGER fairway_dimensions_date_info
         BEFORE UPDATE ON fairway_dimensions
         FOR EACH ROW EXECUTE PROCEDURE update_date_info()
+    CREATE CONSTRAINT TRIGGER fairway_dimensions_area_unique
+        AFTER INSERT OR UPDATE OF area, staging_done ON fairway_dimensions
+        FOR EACH ROW EXECUTE FUNCTION prevent_st_equals('area', 'staging_done')
 
     --
     -- Bottlenecks
@@ -1217,7 +1220,7 @@
     'queued',
     'running',
     'failed', 'unchanged', 'pending',
-    'accepted', 'declined'
+    'accepted', 'declined', 'reviewed'
 );
 
 CREATE TYPE log_type AS ENUM ('info', 'warn', 'error');
@@ -1267,8 +1270,10 @@
         data       TEXT,
         summary    TEXT
     )
-
-    CREATE INDEX enqueued_idx ON imports(enqueued, state)
+    -- Mainly for listing imports in clients:
+    CREATE INDEX enqueued_idx ON imports(enqueued)
+    -- For fast retrieval of queued imports by the import queue in backend:
+    CREATE INDEX state_idx ON imports(state)
 
     CREATE TABLE import_logs (
         import_id int NOT NULL REFERENCES imports(id)
--- a/schema/gemma_tests.sql	Mon Mar 23 15:29:55 2020 +0100
+++ b/schema/gemma_tests.sql	Fri Mar 27 15:57:40 2020 +0100
@@ -52,3 +52,38 @@
     $$,
     23505, NULL,
     'No duplicate geometries can be inserted into waterway_area');
+
+SELECT throws_ok($$
+    INSERT INTO waterway.fairway_dimensions (
+        area, level_of_service,
+        min_width, max_width, min_depth, source_organization
+    ) VALUES (
+        ST_GeogFromText('MULTIPOLYGON(((0 0, 1 1, 1 0, 0 0)))'), 3,
+        100, 200, 2, 'test'
+    ), (
+        ST_GeogFromText('MULTIPOLYGON(((0 0, 1 1, 1 0, 0 0)))'), 3,
+        100, 200, 2, 'test'
+    )
+    $$,
+    23505, NULL,
+    'No duplicate geometries can be inserted into fairway_dimensions');
+
+SELECT lives_ok($$
+    INSERT INTO waterway.fairway_dimensions (
+        area, level_of_service,
+        min_width, max_width, min_depth, source_organization, staging_done
+    ) VALUES (
+        ST_GeogFromText('MULTIPOLYGON(((0 0, 1 1, 1 0, 0 0)))'), 3,
+        100, 200, 2, 'test', false
+    ), (
+        ST_GeogFromText('MULTIPOLYGON(((0 0, 1 1, 1 0, 0 0)))'), 3,
+        100, 200, 2, 'test', true
+    )
+    $$,
+    'Duplicate fairway area can be inserted if stage_done differs');
+
+SELECT throws_ok($$
+    UPDATE waterway.fairway_dimensions SET staging_done = true
+    $$,
+    23505, NULL,
+    'No duplicate fairway area can be released from staging area');
--- a/schema/run_tests.sh	Mon Mar 23 15:29:55 2020 +0100
+++ b/schema/run_tests.sh	Fri Mar 27 15:57:40 2020 +0100
@@ -80,7 +80,7 @@
     -c 'SET client_min_messages TO WARNING' \
     -c "DROP ROLE IF EXISTS $TEST_ROLES" \
     -f "$BASEDIR"/tap_tests_data.sql \
-    -c "SELECT plan(85 + (
+    -c "SELECT plan(88 + (
             SELECT count(*)::int
                 FROM information_schema.tables
                 WHERE table_schema = 'waterway'))" \
--- a/schema/search_functions.sql	Mon Mar 23 15:29:55 2020 +0100
+++ b/schema/search_functions.sql	Fri Mar 27 15:57:40 2020 +0100
@@ -4,7 +4,7 @@
 -- SPDX-License-Identifier: AGPL-3.0-or-later
 -- License-Filename: LICENSES/AGPL-3.0.txt
 
--- Copyright (C) 2018,2019 by via donau
+-- Copyright (C) 2018,2019,2020 by via donau
 --   – Österreichische Wasserstraßen-Gesellschaft mbH
 -- Software engineering by Intevation GmbH
 
@@ -42,7 +42,10 @@
 END;
 $$;
 
-CREATE OR REPLACE FUNCTION search_bottlenecks(search_string text) RETURNS jsonb
+CREATE OR REPLACE FUNCTION search_bottlenecks(
+  search_string text,
+  search_time timestamp with time zone)
+RETURNS jsonb
   LANGUAGE plpgsql STABLE PARALLEL SAFE
   AS $$
 DECLARE
@@ -57,7 +60,7 @@
             FROM waterway.bottlenecks
             WHERE (objnam ILIKE '%' || search_string || '%'
                    OR bottleneck_id ILIKE '%' || search_string || '%')
-              AND validity @> now()
+              AND validity @> search_time
           ORDER BY name) r;
   RETURN _result;
 END;
@@ -159,12 +162,16 @@
 END;
 $$;
 
-CREATE OR REPLACE FUNCTION search_most(search_string text) RETURNS jsonb
+CREATE OR REPLACE FUNCTION search_most(
+  search_string text,
+  search_time timestamp with time zone)
+RETURNS jsonb
   LANGUAGE plpgsql STABLE PARALLEL SAFE
   AS $$
 BEGIN
   RETURN search_hectometre(search_string)
-         || search_bottlenecks(search_string)
+         || search_bottlenecks(search_string,
+                               COALESCE(search_time, current_timestamp))
          || search_gauges(search_string)
          || search_sections(search_string)
          || search_stretches(search_string)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/schema/updates/1430/01.bottlenecks_geoserver_add_time.sql	Fri Mar 27 15:57:40 2020 +0100
@@ -0,0 +1,108 @@
+CREATE TEMP TABLE base_views (name, def) AS VALUES (
+    'gauges_base_view', $$
+    SELECT
+        g.location,
+        isrs_asText(g.location) AS isrs_code,
+        g.objname,
+        g.geom,
+        g.applicability_from_km,
+        g.applicability_to_km,
+        g.validity,
+        g.zero_point,
+        g.geodref,
+        g.date_info,
+        g.source_organization,
+        g.erased,
+        r.rwls AS reference_water_levels,
+        wl.measure_date AS gm_measuredate,
+        wl.water_level AS gm_waterlevel,
+        wl.n AS gm_n_14d,
+        fca.forecast_accuracy_3d,
+        fca.forecast_accuracy_1d
+    FROM waterway.gauges g
+        LEFT JOIN (SELECT location, validity,
+                    json_strip_nulls(json_object_agg(
+                        coalesce(depth_reference, 'empty'), value)) AS rwls
+                FROM waterway.gauges_reference_water_levels
+                GROUP BY location, validity) AS r
+            USING (location, validity)
+        LEFT JOIN (SELECT DISTINCT ON (location)
+                    location,
+                    date_issue,
+                    measure_date,
+                    water_level,
+                    count(*) OVER (PARTITION BY location) AS n
+                FROM waterway.gauge_measurements
+                WHERE measure_date
+                    >= current_timestamp - '14 days 00:15'::interval
+                ORDER BY location, measure_date DESC) AS wl
+            USING (location)
+        LEFT JOIN (SELECT DISTINCT ON (location)
+                    location,
+                    date_issue,
+                    max(acc) FILTER (WHERE measure_date
+                            <= current_timestamp + '1 day'::interval)
+                        OVER loc_date_issue AS forecast_accuracy_1d,
+                    max(acc) OVER loc_date_issue AS forecast_accuracy_3d
+                FROM (SELECT location, date_issue, measure_date,
+                        GREATEST(water_level - lower(conf_interval),
+                            upper(conf_interval) - water_level) AS acc
+                    FROM waterway.gauge_predictions
+                    WHERE date_issue
+                        >= current_timestamp - '14 days 00:15'::interval
+                        AND measure_date BETWEEN current_timestamp
+                            AND current_timestamp + '3 days'::interval) AS acc
+                WINDOW loc_date_issue AS (PARTITION BY location, date_issue)
+                ORDER BY location, date_issue DESC) AS fca
+            ON fca.location = g.location AND fca.date_issue >= wl.date_issue
+    $$);
+
+UPDATE sys_admin.published_services
+    SET
+        wmst_attribute = 'valid_from',
+        wmst_end_attribute = 'valid_to',
+        view_def = $$
+        SELECT
+            b.id,
+            lower(b.validity) AS valid_from,
+            COALESCE(upper(b.validity), current_timestamp) AS valid_to,
+            b.bottleneck_id,
+            b.objnam,
+            b.nobjnm,
+            b.area,
+            b.rb,
+            b.lb,
+            b.responsible_country,
+            b.revisiting_time,
+            b.limiting,
+            b.date_info,
+            b.source_organization,
+            g.objname AS gauge_objname,
+            g.reference_water_levels,
+            fal.date_info AS fa_date_info,
+            fal.critical AS fa_critical,
+            g.gm_measuredate,
+            g.gm_waterlevel,
+            g.gm_n_14d,
+            srl.date_max,
+            g.forecast_accuracy_3d,
+            g.forecast_accuracy_1d
+        FROM waterway.bottlenecks b
+            LEFT JOIN (
+    $$ || (SELECT def FROM base_views WHERE name = 'gauges_base_view') || $$
+            ) AS g
+                ON b.gauge_location = g.location
+                    AND g.validity @> current_timestamp
+            LEFT JOIN (SELECT DISTINCT ON (bottleneck_id)
+                        bottleneck_id, date_info, critical
+                    FROM waterway.fairway_availability
+                    ORDER BY bottleneck_id, date_info DESC) AS fal
+                ON b.bottleneck_id = fal.bottleneck_id
+            LEFT JOIN (SELECT DISTINCT ON (bottleneck_id)
+                        bottleneck_id, max(date_info) AS date_max
+                    FROM waterway.sounding_results
+                    GROUP BY bottleneck_id
+                    ORDER BY bottleneck_id DESC) AS srl
+                ON b.bottleneck_id = srl.bottleneck_id
+            $$
+    WHERE schema = 'waterway' AND name = 'bottlenecks_geoserver';
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/schema/updates/1431/01.prevent_equal_fairway_areas.sql	Fri Mar 27 15:57:40 2020 +0100
@@ -0,0 +1,4 @@
+CREATE CONSTRAINT TRIGGER fairway_dimensions_area_unique
+    AFTER INSERT OR UPDATE OF area, staging_done
+    ON waterway.fairway_dimensions
+    FOR EACH ROW EXECUTE FUNCTION prevent_st_equals('area', 'staging_done');
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/schema/updates/1432/01.search_functions-time.sql	Fri Mar 27 15:57:40 2020 +0100
@@ -0,0 +1,58 @@
+-- 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,2020 by via donau
+--   – Österreichische Wasserstraßen-Gesellschaft mbH
+-- Software engineering by Intevation GmbH
+
+-- Author(s):
+--  * Sascha Wilde <wilde@intevation.de>
+
+-- This update adds the new argument "search_time" to
+-- search_bottlenecks() and the meta function search_most().
+
+DROP FUNCTION search_bottlenecks(text);
+CREATE OR REPLACE FUNCTION search_bottlenecks(
+  search_string text,
+  search_time timestamp with time zone)
+RETURNS jsonb
+  LANGUAGE plpgsql STABLE PARALLEL SAFE
+  AS $$
+DECLARE
+  _result jsonb;
+BEGIN
+  SELECT COALESCE(json_agg(r),'[]')
+    INTO _result
+    FROM (SELECT objnam AS name,
+                 ST_AsGeoJSON(ST_Envelope(area::geometry))::json AS geom,
+                 'bottleneck' AS type,
+                 bottleneck_id AS location
+            FROM waterway.bottlenecks
+            WHERE (objnam ILIKE '%' || search_string || '%'
+                   OR bottleneck_id ILIKE '%' || search_string || '%')
+              AND validity @> search_time
+          ORDER BY name) r;
+  RETURN _result;
+END;
+$$;
+
+DROP FUNCTION search_most(text);
+CREATE OR REPLACE FUNCTION search_most(
+  search_string text,
+  search_time timestamp with time zone)
+RETURNS jsonb
+  LANGUAGE plpgsql STABLE PARALLEL SAFE
+  AS $$
+BEGIN
+  RETURN search_hectometre(search_string)
+         || search_bottlenecks(search_string,
+                               COALESCE(search_time, current_timestamp))
+         || search_gauges(search_string)
+         || search_sections(search_string)
+         || search_stretches(search_string)
+         || search_cities(search_string);
+END;
+$$;
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/schema/updates/1433/01.add_state.sql	Fri Mar 27 15:57:40 2020 +0100
@@ -0,0 +1,19 @@
+-- DROP and re-CREATE type because adding a value isn't possible in transaction
+-- https://www.postgresql.org/docs/11/sql-altertype.html#id-1.9.3.42.7
+
+ALTER TABLE import.imports
+    ALTER COLUMN state DROP DEFAULT,
+    ALTER COLUMN state TYPE varchar;
+
+DROP TYPE import_state;
+
+CREATE TYPE import_state AS ENUM (
+    'queued',
+    'running',
+    'failed', 'unchanged', 'pending',
+    'accepted', 'declined', 'reviewed'
+);
+
+ALTER TABLE import.imports
+    ALTER COLUMN state TYPE import_state USING CAST(state AS import_state),
+    ALTER COLUMN state SET DEFAULT 'queued';
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/schema/updates/1434/01.allow_job_delete_sys_admin.sql	Fri Mar 27 15:57:40 2020 +0100
@@ -0,0 +1,1 @@
+GRANT DELETE ON import.imports, import.import_logs TO sys_admin;
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/schema/updates/1435/01.add_import_state_idx.sql	Fri Mar 27 15:57:40 2020 +0100
@@ -0,0 +1,3 @@
+DROP INDEX import.enqueued_idx;
+CREATE INDEX enqueued_idx ON import.imports(enqueued);
+CREATE INDEX state_idx ON import.imports(state);
--- a/schema/version.sql	Mon Mar 23 15:29:55 2020 +0100
+++ b/schema/version.sql	Fri Mar 27 15:57:40 2020 +0100
@@ -1,1 +1,1 @@
-INSERT INTO gemma_schema_version(version) VALUES (1429);
+INSERT INTO gemma_schema_version(version) VALUES (1435);