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