diff --git a/package-lock.json b/package-lock.json index 74352e4f1..2fd2093c2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,11 +8,12 @@ "name": "fews-web-oc", "version": "0.5.0", "dependencies": { - "@deltares/fews-pi-requests": "^0.6.3-beta.7", + "@deltares/fews-pi-requests": "^0.6.4-beta.0", "@deltares/fews-ssd-requests": "^0.2.11", "@deltares/fews-ssd-webcomponent": "^0.3.0-alpha.0", "@deltares/fews-web-oc-charts": "^3.0.0-alpha.7", "@deltares/fews-wms-requests": "^0.1.11-alpha.4", + "@mapbox/mapbox-gl-draw": "^1.4.2", "@mdi/font": "^6.4.95", "@turf/helpers": "^6.5.0", "@turf/projection": "^6.5.0", @@ -47,6 +48,7 @@ "@types/jest": "^27.0.0", "@types/lodash": "^4.14.182", "@types/luxon": "^3.3.0", + "@types/mapbox__mapbox-gl-draw": "^1.4.0", "@types/mapbox-gl": "^2.7.10", "@types/splitpanes": "^2.2.1", "@typescript-eslint/eslint-plugin": "^5.28.0", @@ -1724,9 +1726,9 @@ "license": "MIT" }, "node_modules/@deltares/fews-pi-requests": { - "version": "0.6.3-beta.7", - "resolved": "https://registry.npmjs.org/@deltares/fews-pi-requests/-/fews-pi-requests-0.6.3-beta.7.tgz", - "integrity": "sha512-rBEBiJZbQ1cqmfLR1FzWrv47Jlq/khApTh/EGgt0p+uQxbCoc/hZLdBkFCludgGf747RoChmwFB4BW++CvhRLg==", + "version": "0.6.4-beta.0", + "resolved": "https://registry.npmjs.org/@deltares/fews-pi-requests/-/fews-pi-requests-0.6.4-beta.0.tgz", + "integrity": "sha512-E+fNiVzCLBWp9+uNdUAp2OCdGQb4NpODmd5QjQpk9AAOk2G8N1icFc5+i+bETK5o2IenPQkrj/4GMkd3SPe3HA==", "dependencies": { "@deltares/fews-web-oc-utils": "^0.2.1-alpha.1" }, @@ -3106,6 +3108,11 @@ "dev": true, "license": "MIT" }, + "node_modules/@mapbox/extent": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@mapbox/extent/-/extent-0.4.0.tgz", + "integrity": "sha512-MSoKw3qPceGuupn04sdaJrFeLKvcSETVLZCGS8JA9x6zXQL3FWiKaIXYIZEDXd5jpXpWlRxinCZIN49yRy0C9A==" + }, "node_modules/@mapbox/fusspot": { "version": "0.4.0", "license": "BSD 2-Clause", @@ -3114,6 +3121,50 @@ "xtend": "^4.0.1" } }, + "node_modules/@mapbox/geojson-area": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/@mapbox/geojson-area/-/geojson-area-0.2.2.tgz", + "integrity": "sha512-bBqqFn1kIbLBfn7Yq1PzzwVkPYQr9lVUeT8Dhd0NL5n76PBuXzOcuLV7GOSbEB1ia8qWxH4COCvFpziEu/yReA==", + "dependencies": { + "wgs84": "0.0.0" + } + }, + "node_modules/@mapbox/geojson-coords": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/@mapbox/geojson-coords/-/geojson-coords-0.0.2.tgz", + "integrity": "sha512-YuVzpseee/P1T5BWyeVVPppyfmuXYHFwZHmybkqaMfu4BWlOf2cmMGKj2Rr92MwfSTOCSUA0PAsVGRG8akY0rg==", + "dependencies": { + "@mapbox/geojson-normalize": "0.0.1", + "geojson-flatten": "^1.0.4" + } + }, + "node_modules/@mapbox/geojson-extent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@mapbox/geojson-extent/-/geojson-extent-1.0.1.tgz", + "integrity": "sha512-hh8LEO3djT4fqfr8sSC6wKt+p0TMiu+KOLMBUiFOyj+zGq7+IXwQGl0ppCVDkyzCewyd9LoGe9zAvDxXrLfhLw==", + "dependencies": { + "@mapbox/extent": "0.4.0", + "@mapbox/geojson-coords": "0.0.2", + "rw": "~0.1.4", + "traverse": "~0.6.6" + }, + "bin": { + "geojson-extent": "bin/geojson-extent" + } + }, + "node_modules/@mapbox/geojson-extent/node_modules/rw": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/rw/-/rw-0.1.4.tgz", + "integrity": "sha512-vSj3D96kMcjNyqPcp65wBRIDImGSrUuMxngNNxvw8MQaO+aQ6llzRPH7XcJy5zrpb3wU++045+Uz/IDIM684iw==" + }, + "node_modules/@mapbox/geojson-normalize": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/@mapbox/geojson-normalize/-/geojson-normalize-0.0.1.tgz", + "integrity": "sha512-82V7YHcle8lhgIGqEWwtXYN5cy0QM/OHq3ypGhQTbvHR57DF0vMHMjjVSQKFfVXBe/yWCBZTyOuzvK7DFFnx5Q==", + "bin": { + "geojson-normalize": "geojson-normalize" + } + }, "node_modules/@mapbox/geojson-rewind": { "version": "0.5.2", "license": "ISC", @@ -3141,6 +3192,20 @@ "node": ">= 0.6" } }, + "node_modules/@mapbox/mapbox-gl-draw": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/@mapbox/mapbox-gl-draw/-/mapbox-gl-draw-1.4.2.tgz", + "integrity": "sha512-Zvl5YN+tIuYZlzPmuzOgkoJsZX6sHMQsnFI+O3ox8EwYkpLO2w0U2FvVhQuVnq1Yys12x6UnF+0IPoEdBy2UfA==", + "dependencies": { + "@mapbox/geojson-area": "^0.2.2", + "@mapbox/geojson-extent": "^1.0.1", + "@mapbox/geojson-normalize": "^0.0.1", + "@mapbox/point-geometry": "^0.1.0", + "hat": "0.0.3", + "lodash.isequal": "^4.5.0", + "xtend": "^4.0.2" + } + }, "node_modules/@mapbox/mapbox-gl-geocoder": { "version": "4.7.4", "license": "ISC", @@ -3951,6 +4016,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/mapbox__mapbox-gl-draw": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@types/mapbox__mapbox-gl-draw/-/mapbox__mapbox-gl-draw-1.4.0.tgz", + "integrity": "sha512-hcr3NA9Yt3fML6SXaeaQzC6x2ftESob0CrZXR4dUBrZ//SPCU02dGlws3aCXlwqDoQW0mLuK5/UExStJgp0BOg==", + "dev": true, + "dependencies": { + "@types/geojson": "*", + "@types/mapbox-gl": "*" + } + }, "node_modules/@types/mapbox-gl": { "version": "2.7.10", "dev": true, @@ -9771,6 +9846,11 @@ "node": ">=6.9.0" } }, + "node_modules/geojson-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/geojson-flatten/-/geojson-flatten-1.1.1.tgz", + "integrity": "sha512-k/6BCd0qAt7vdqdM1LkLfAy72EsLDy0laNwX0x2h49vfYCiQkRc4PSra8DNEdJ10EKRpwEvDXMb0dBknTJuWpQ==" + }, "node_modules/geojson-vt": { "version": "3.2.1", "license": "ISC" @@ -10060,6 +10140,14 @@ "dev": true, "license": "MIT" }, + "node_modules/hat": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/hat/-/hat-0.0.3.tgz", + "integrity": "sha512-zpImx2GoKXy42fVDSEad2BPKuSQdLcqsCYa48K3zHSzM/ugWuYjLDr8IXxpVuL7uCLHw56eaiLxCRthhOzf5ug==", + "engines": { + "node": "*" + } + }, "node_modules/he": { "version": "1.2.0", "dev": true, @@ -13378,6 +13466,11 @@ "dev": true, "license": "MIT" }, + "node_modules/lodash.isequal": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz", + "integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==" + }, "node_modules/lodash.kebabcase": { "version": "4.1.1", "dev": true, @@ -17181,6 +17274,14 @@ "node": ">=8" } }, + "node_modules/traverse": { + "version": "0.6.7", + "resolved": "https://registry.npmjs.org/traverse/-/traverse-0.6.7.tgz", + "integrity": "sha512-/y956gpUo9ZNCb99YjxG7OaslxZWHfCHAUUfshwqOXmxUIvqLjVO581BT+gM59+QV9tFe6/CGG53tsA1Y7RSdg==", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/trim-newlines": { "version": "3.0.1", "license": "MIT", @@ -18504,6 +18605,11 @@ "node": ">=0.8.0" } }, + "node_modules/wgs84": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/wgs84/-/wgs84-0.0.0.tgz", + "integrity": "sha512-ANHlY4Rb5kHw40D0NJ6moaVfOCMrp9Gpd1R/AIQYg2ko4/jzcJ+TVXYYF6kXJqQwITvEZP4yEthjM7U6rYlljQ==" + }, "node_modules/whatwg-encoding": { "version": "1.0.5", "dev": true, diff --git a/package.json b/package.json index 3211f140c..79a6f6313 100644 --- a/package.json +++ b/package.json @@ -12,11 +12,12 @@ "sonar": "sonar-scanner -Dsonar.host.url=$SONAR_URL -Dsonar.login=$SONAR_TOKEN -Dsonar.projectKey=$SONAR_KEY -Dsonar.projectName='Delft-FEWS Web OC'" }, "dependencies": { - "@deltares/fews-pi-requests": "^0.6.3-beta.7", + "@deltares/fews-pi-requests": "^0.6.4-beta.0", "@deltares/fews-ssd-requests": "^0.2.11", "@deltares/fews-ssd-webcomponent": "^0.3.0-alpha.0", "@deltares/fews-web-oc-charts": "^3.0.0-alpha.7", "@deltares/fews-wms-requests": "^0.1.11-alpha.4", + "@mapbox/mapbox-gl-draw": "^1.4.2", "@mdi/font": "^6.4.95", "@turf/helpers": "^6.5.0", "@turf/projection": "^6.5.0", @@ -51,6 +52,7 @@ "@types/jest": "^27.0.0", "@types/lodash": "^4.14.182", "@types/luxon": "^3.3.0", + "@types/mapbox__mapbox-gl-draw": "^1.4.0", "@types/mapbox-gl": "^2.7.10", "@types/splitpanes": "^2.2.1", "@typescript-eslint/eslint-plugin": "^5.28.0", diff --git a/src/components/ElevationSlider.vue b/src/components/ElevationSlider.vue index 8c02bc93f..3b7099e9e 100644 --- a/src/components/ElevationSlider.vue +++ b/src/components/ElevationSlider.vue @@ -51,6 +51,6 @@ export default class ElevationSlider extends Vue { z-index: 100; position: absolute; right: 20px; - top: 50px; + bottom: 50px; } diff --git a/src/components/MapComponent.vue b/src/components/MapComponent.vue index 5f2aa49a1..3930039e8 100644 --- a/src/components/MapComponent.vue +++ b/src/components/MapComponent.vue @@ -1,5 +1,6 @@ + + + diff --git a/src/lib/MapBox/DownloadControl.ts b/src/lib/MapBox/DownloadControl.ts new file mode 100644 index 000000000..37cb14852 --- /dev/null +++ b/src/lib/MapBox/DownloadControl.ts @@ -0,0 +1,50 @@ +import Regridder from '@/components/Regridder.vue' + +export class DownloadControl { + bbox: number[] | null = null + private container!: HTMLElement + private vueComponent: Regridder + + constructor(bbox: number[] | null, vueComponent: Regridder) { + this.bbox = bbox + this.vueComponent = vueComponent + } + + onAdd() { + const btn = document.createElement('button') + btn.className = 'mapboxgl-ctrl-download' + btn.type = 'button' + if (this.bbox === null) { + btn.disabled = true + } else { + btn.disabled = false + } + + const icon = document.createElement('i') + icon.id = 'download-icon' + icon.className = 'mdi mdi-download mdi-24px' + + btn.appendChild(icon) + btn.onclick = () => { + this.showPopup() + } + + this.container = document.createElement('div') + this.container.className = 'mapboxgl-ctrl-group mapboxgl-ctrl' + this.container.appendChild(btn) + + return this.container + } + + onRemove() { + if (this.container && this.container.parentNode) { + this.container.parentNode.removeChild(this.container) + } + } + + private showPopup() { + if (this.vueComponent) { + this.vueComponent.downloadDialog = true + } + } +} diff --git a/src/lib/MapBox/DrawRectangleMode.ts b/src/lib/MapBox/DrawRectangleMode.ts new file mode 100644 index 000000000..696c25429 --- /dev/null +++ b/src/lib/MapBox/DrawRectangleMode.ts @@ -0,0 +1,159 @@ +import { DrawCustomModeThis, DrawCustomMode, MapMouseEvent, DrawActionableState, MapTouchEvent, DrawPolygon } from '@mapbox/mapbox-gl-draw'; +import { GeoJSON } from 'geojson' + +interface State { + startPoint?: [number, number] + endPoint?: [number, number] + rectangle: DrawPolygon +} + +const doubleClickZoom = { + enable: (ctx: any) => { // library type definition is not complete + setTimeout(() => { + // First check we've got a map and some context. + if ( + !ctx.map || + !ctx.map.doubleClickZoom || + !ctx._ctx || + !ctx._ctx.store || + !ctx._ctx.store.getInitialConfigValue + ) return + + // Now check initial state wasn't false (we leave it disabled if so) + if (!ctx._ctx.store.getInitialConfigValue("doubleClickZoom")) return + ctx.map.doubleClickZoom.enable() + }) + }, + disable: (ctx: DrawCustomModeThis) => { + setTimeout(() => { + if (!ctx.map || !ctx.map.doubleClickZoom) return + + // Always disable here, as it's necessary in some cases. + ctx.map.doubleClickZoom.disable() + }) + } +} + +const DrawRectangle: DrawCustomMode = { + // When the mode starts this function will be called. + onSetup: function(this: DrawCustomModeThis, opts: any) { + const rectangle = this.newFeature({ + type: "Feature", + properties: { + noActiveFeature: true, + }, + geometry: { + type: "Polygon", + coordinates: [[]] + } + }) + + this.addFeature(rectangle) + this.clearSelectedFeatures() + doubleClickZoom.disable(this) + this.updateUIClasses({ mouse: "add" }) + this.setActionableState({ + trash: true + } as DrawActionableState) + + return { + rectangle + } + }, + // support mobile taps + onTap: function(this: any, state: State, e: MapTouchEvent) { // library type definition is not complete + // emulate 'move mouse' to update feature coords + if (state.startPoint) this.onMouseMove(state, e) + + // emulate onClick + this.onClick(state, e) + }, + // Whenever a user clicks on the map, Draw will call `onClick` + onClick: function(this: DrawCustomModeThis, state: State, e: MapMouseEvent) { + // if state.startPoint exist, means its second click + // change to simple_select mode + if ( + state.startPoint && + (state.startPoint[0] !== e.lngLat.lng || + state.startPoint[1] !== e.lngLat.lat) + ) { + this.updateUIClasses({ mouse: "pointer" }) + state.endPoint = [e.lngLat.lng, e.lngLat.lat] + this.changeMode("simple_select", { featuresId: state.rectangle.id }) + } + + // on the first click, save clicked point coords as starting for the rectangle + const startPoint: [number, number] = [e.lngLat.lng, e.lngLat.lat] + state.startPoint = startPoint + }, + onMouseMove: function(this: DrawCustomModeThis, state: State, e: MapMouseEvent) { + // if startPoint, update the feature coordinates, using the bounding box concept + // we are simply using the startingPoint coordinates and the current Mouse Position + // coordinates to calculate the bounding box on the fly, which will be our rectangle + if (state.startPoint) { + state.rectangle.updateCoordinate( + "0.0", + state.startPoint[0], + state.startPoint[1] + ) // minX, minY - the starting point + state.rectangle.updateCoordinate( + "0.1", + e.lngLat.lng, + state.startPoint[1] + ) // maxX, minY + state.rectangle.updateCoordinate("0.2", e.lngLat.lng, e.lngLat.lat) // maxX, maxY + state.rectangle.updateCoordinate( + "0.3", + state.startPoint[0], + e.lngLat.lat + ) // minX, maxY + state.rectangle.updateCoordinate( + "0.4", + state.startPoint[0], + state.startPoint[1] + ) // minX, minY - ending point (equals to starting point) + } + }, + // Whenever a user clicks on a key while focused on the map, it will be sent here + onKeyUp: function(this: DrawCustomModeThis, state: State, e: KeyboardEvent) { + if (e.keyCode === 27) return this.changeMode("simple_select") + }, + onStop: function (this: DrawCustomModeThis, state: State) { + doubleClickZoom.enable(this) + this.updateUIClasses({ mouse: "none" }) + this.activateUIButton() + + // check to see if we've deleted this feature + if (typeof state.rectangle.id === 'string' && this.getFeature(state.rectangle.id) === undefined) return; + + // Remove the last added coordinate + state.rectangle.removeCoordinate("0.4") + + if (state.rectangle.isValid()) { + this.map.fire("draw.create", { + features: [state.rectangle.toGeoJSON()], + }) + } else { + if (typeof state.rectangle.id === "string") { + this.deleteFeature(state.rectangle.id, { silent: true }) + } + this.changeMode("simple_select", {}, { silent: true }) + } + }, + toDisplayFeatures: function (this: DrawCustomModeThis, state: State, geojson: any, display: (geojson: GeoJSON) => void) { + const isActivePolygon = geojson.properties.id === state.rectangle.id + geojson.properties.active = isActivePolygon ? "true" : "false" + + if (!isActivePolygon) return display(geojson) + // Only render the rectangular polygon if it has the starting point + if (!state.startPoint) return display(geojson) + return display(geojson) + }, + onTrash: function(this: DrawCustomModeThis, state: State) { + if (typeof state.rectangle.id === "string") { + this.deleteFeature(state.rectangle.id, { silent: true }) + } + this.changeMode("simple_select") + } +} +export default DrawRectangle diff --git a/src/views/MetocDataView.vue b/src/views/MetocDataView.vue index dbf5f06ac..c46191d68 100644 --- a/src/views/MetocDataView.vue +++ b/src/views/MetocDataView.vue @@ -26,6 +26,10 @@ v-if="showLocationsLayer" :options="selectedLocationsLayerOptions" /> +
@@ -128,6 +132,7 @@ import LocationsLayerSearchControl from '@/components/LocationsLayerSearchContro import MapComponent from '@/components/MapComponent.vue' import WMSInfoPanel from '@/components/WMSInfoPanel.vue'; import { Layer } from '@deltares/fews-wms-requests'; +import Regridder from '@/components/Regridder.vue' const defaultGeoJsonSource: GeoJSONSourceRaw = { type: 'geojson', @@ -177,7 +182,8 @@ const selectedLocationsLayerOptions: CircleLayer = { MapboxLayer, MapComponent, MetocSidebar, - WMSInfoPanel + WMSInfoPanel, + Regridder } }) export default class MetocDataView extends Mixins(WMSMixin, TimeSeriesMixin, PiRequestsMixin) { @@ -555,6 +561,16 @@ export default class MetocDataView extends Mixins(WMSMixin, TimeSeriesMixin, PiR return layer ?? null } + get firstValueTime(): string | null { + if (this.times.length === 0) return null + return this.times[0].toISOString() + } + + get lastValueTime(): string | null { + if (this.times.length === 0) return null + return this.times[this.times.length - 1].toISOString() + } + get dataSources(): DataSource[] { return this.currentDataLayer?.dataSources ?? [] }