changeset 5091:1154b73328ec

Merged time-sliding branch into default.
author Sascha L. Teichmann <sascha.teichmann@intevation.de>
date Tue, 24 Mar 2020 11:23:26 +0100
parents 56c589f7435d (current diff) 70bd5c824639 (diff)
children 8dc27fc1d05c f64ff954ee31
files client/src/components/map/layers.js client/src/components/map/styles.js
diffstat 16 files changed, 1679 insertions(+), 1133 deletions(-) [+]
line wrap: on
line diff
--- a/client/src/components/App.vue	Fri Mar 20 17:24:03 2020 +0100
+++ b/client/src/components/App.vue	Tue Mar 24 11:23:26 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"),
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/client/src/components/TimeSlider.vue	Tue Mar 24 11:23:26 2020 +0100
@@ -0,0 +1,388 @@
+<template>
+  <div
+    id="slider"
+    :class="[
+      'd-flex box ui-element rounded bg-white flex-row',
+      { expanded: showTimeSlider }
+    ]"
+    :style="reposition"
+  >
+    <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;
+}
+#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"
+    ]),
+    reposition() {
+      // reposition time slider in case of opened diagram
+      if (["DEFAULT", "COMPARESURVEYS"].indexOf(this.paneSetup) === -1) {
+        const height = document.getElementById("main").clientHeight + 1;
+        return `bottom: ${height}px`;
+      } else {
+        return "";
+      }
+    },
+    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	Fri Mar 20 17:24:03 2020 +0100
+++ b/client/src/components/identify/Identify.vue	Tue Mar 24 11:23:26 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	Fri Mar 20 17:24:03 2020 +0100
+++ b/client/src/components/importoverview/FairwayDimensionDetail.vue	Tue Mar 24 11:23:26 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	Fri Mar 20 17:24:03 2020 +0100
+++ b/client/src/components/layers/Layers.vue	Tue Mar 24 11:23:26 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	Tue Mar 24 11:23:26 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	Tue Mar 24 11:23:26 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	Fri Mar 20 17:24:03 2020 +0100
+++ b/client/src/components/map/Map.vue	Tue Mar 24 11:23:26 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	Fri Mar 20 17:24:03 2020 +0100
+++ b/client/src/components/map/Zoom.vue	Tue Mar 24 11:23:26 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	Fri Mar 20 17:24:03 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	Fri Mar 20 17:24:03 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	Tue Mar 24 11:23:26 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	Fri Mar 20 17:24:03 2020 +0100
+++ b/client/src/components/toolbar/Toolbar.vue	Tue Mar 24 11:23:26 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	Fri Mar 20 17:24:03 2020 +0100
+++ b/client/src/lib/session.js	Tue Mar 24 11:23:26 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	Fri Mar 20 17:24:03 2020 +0100
+++ b/client/src/store/application.js	Tue Mar 24 11:23:26 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	Fri Mar 20 17:24:03 2020 +0100
+++ b/client/src/store/map.js	Tue Mar 24 11:23:26 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 }