changeset 5455:2ad3a29e0e14 uiimprovements

Merged default into uiimprovements branch.
author Sascha L. Teichmann <sascha.teichmann@intevation.de>
date Wed, 14 Jul 2021 20:22:32 +0200
parents b4216db975e3 (current diff) 7a96321be219 (diff)
children 661af6353d3b
files client/src/components/Bottlenecks.vue client/src/components/KeyboardHandler.vue
diffstat 27 files changed, 980 insertions(+), 207 deletions(-) [+]
line wrap: on
line diff
--- a/client/src/components/Pdftool.vue	Wed Jul 14 16:21:27 2021 +0200
+++ b/client/src/components/Pdftool.vue	Wed Jul 14 20:22:32 2021 +0200
@@ -283,7 +283,14 @@
           bbox: currentExtent,
           geometryName: "areas"
         };
-        if (this.selectedSurvey) {
+        const survey = this.selectedSurvey;
+        if (survey) {
+          if (survey["survey_type"] === "marking") {
+            params["featureTypes"] = [
+              "sounding_results_marking_points_geoserver"
+            ];
+            params["geometryName"] = "points";
+          }
           params["filter"] = equalToFilter(
             "bottleneck_id",
             this.selectedSurvey.bottleneck_id
@@ -327,7 +334,7 @@
           this.generatePDF(soundingInfo);
         })
         .catch(error => {
-          console.log(error);
+          // console.log(error);
           let message = "Backend not reachable";
           if (error.response) {
             const { status, data } = error.response;
@@ -808,7 +815,11 @@
           this.soundingInfo.feature.properties.zpg_exception;
 
         let survey = this.selectedSurvey;
-
+        const SURVEYTYPES = {
+          marking: "Marking Vessel",
+          multi: "Multibeam",
+          single: "Singlebeam"
+        };
         // determine text dimensions
         // this is a little bit cumbersome but we need to separate width
         // calculations and writing
@@ -818,7 +829,8 @@
         let str1_1 = this.$gettext("Bottleneck") + ": ";
         let str1_2 = this.selectedBottleneck;
         let str2_1 = this.$gettext("Survey date") + ": ";
-        let str2_2 = survey.date_info;
+        let str2_2 =
+          survey.date_info + " (" + SURVEYTYPES[survey["survey_type"]] + ")";
         let str3_1 = this.$gettext("Ref gauge") + ": ";
         let str3_2 = survey.gauge_objname;
         let str4_1 = this.$gettext("Depth relativ to") + ": ";
--- a/client/src/components/fairway/BottleneckDialogue.vue	Wed Jul 14 16:21:27 2021 +0200
+++ b/client/src/components/fairway/BottleneckDialogue.vue	Wed Jul 14 20:22:32 2021 +0200
@@ -103,6 +103,7 @@
               <select
                 v-model="additionalSurvey"
                 class="form-control form-control-sm small"
+                :disabled="!areDifferecesAllowed"
               >
                 <option :value="null">None</option>
                 <option
@@ -264,7 +265,7 @@
               <button
                 class="btn btn-info btn-sm w-100"
                 @click="toggleCutTool"
-                :disabled="!selectedSurvey"
+                :disabled="!isCutAllowed"
               >
                 <font-awesome-icon :icon="cutToolEnabled ? 'times' : 'plus'" />
                 {{ cutToolEnabled ? "Cancel" : "New" }}
@@ -330,6 +331,7 @@
  *
  * Author(s):
  * Markus Kottländer <markus.kottlaender@intevation.de>
+ * Thomas Junk <thomas.junk@intevation.de>
  */
 import { mapState, mapGetters } from "vuex";
 import Feature from "ol/Feature";
@@ -358,6 +360,16 @@
       "surveys",
       "surveysLoading"
     ]),
+    selectedSurveyIsMarking() {
+      if (!this.selectedSurvey) return false;
+      return this.selectedSurvey["survey_type"] === "marking";
+    },
+    areDifferecesAllowed() {
+      return !this.selectedSurveyIsMarking;
+    },
+    isCutAllowed() {
+      return !this.selectedSurveyIsMarking && !!this.selectedSurvey;
+    },
     isAllowedToDelete() {
       const userCountryCode = this.userCountries[this.user];
       const bottleneck = this.bottlenecksList.find(
--- a/client/src/components/importconfiguration/types/Soundingresults.vue	Wed Jul 14 16:21:27 2021 +0200
+++ b/client/src/components/importconfiguration/types/Soundingresults.vue	Wed Jul 14 20:22:32 2021 +0200
@@ -254,6 +254,7 @@
       this.token = null;
       this.eMailNotification = false;
       this.messages = [];
+      this.beamType = "";
     },
     fileSelected(e) {
       const files = e.target.files || e.dataTransfer.files;
@@ -296,10 +297,10 @@
         }
       })
         .then(response => {
-          if (response.data.meta) {
+          if (response.data && response.data.meta) {
             const { bottleneck, date, epsg } = response.data.meta;
             const depthReference = response.data.meta["depth-reference"];
-            const singlebeam = response.data.meta["single-beam"];
+            const surveyType = response.data.meta["survey-type"];
             this.negateZ = response.data.meta["negate-z"];
             this.bottleneck = this.bottlenecks.find(
               bn => bn.properties.objnam === bottleneck
@@ -307,9 +308,20 @@
             this.depthReference = depthReference;
             this.importDate = new Date(date).toISOString().split("T")[0];
             this.projection = epsg;
-            this.beamType = singlebeam
-              ? this.$options.BEAMTYPES.SINGLEBEAM
-              : this.$options.BEAMTYPES.MULTIBEAM;
+            switch (surveyType) {
+              case "single":
+                this.beamType = this.$options.BEAMTYPES.SINGLEBEAM;
+                break;
+              case "multi":
+                this.beamType = this.$options.BEAMTYPES.MULTIBEAM;
+                break;
+              case "marking":
+                this.beamType = this.$options.BEAMTYPES.MARKING;
+                break;
+              default:
+                this.beamType = this.$options.BEAMTYPES.MULTIBEAM;
+                break;
+            }
           }
           this.importState = IMPORTSTATE.EDIT;
           this.token = response.data.token;
@@ -337,11 +349,7 @@
       if (this.depthReference)
         formData.append("depth-reference", this.depthReference);
       if (this.projection) formData.append("epsg", this.projection);
-      if (this.beamType)
-        formData.append(
-          "single-beam",
-          this.beamType === this.$options.BEAMTYPES.SINGLEBEAM
-        );
+      if (this.beamType) formData.append("survey-type", this.beamType);
       formData.append("negate-z", this.negateZ == true);
       HTTP.post("/imports/sr", formData, {
         headers: {
@@ -458,8 +466,7 @@
               "depth-reference": this.depthReference,
               bottleneck: this.bottleneck.properties.objnam,
               date: this.importDate,
-              "single-beam":
-                this.beamType === this.$options.BEAMTYPES.SINGLEBEAM,
+              "survey-type": this.beamType,
               epsg: Number(this.projection),
               "negate-z": this.negateZ == true
             })
@@ -487,8 +494,9 @@
     }
   },
   BEAMTYPES: {
-    MULTIBEAM: "multi-beam",
-    SINGLEBEAM: "single-beam"
+    MULTIBEAM: "multi",
+    SINGLEBEAM: "single",
+    MARKING: "marking"
   },
   UPLOADLABEL: "choose a .zip or .txt file",
   on: "on",
--- a/client/src/components/layers/layers.js	Wed Jul 14 16:21:27 2021 +0200
+++ b/client/src/components/layers/layers.js	Wed Jul 14 20:22:32 2021 +0200
@@ -386,7 +386,7 @@
             projection: "EPSG:3857",
             url: window.location.origin + "/api/internal/wms",
             params: {
-              LAYERS: "sounding_results_areas_geoserver",
+              LAYERS: "sounding_results",
               VERSION: "1.1.1",
               TILED: true
             },
--- a/client/src/lib/mixins.js	Wed Jul 14 16:21:27 2021 +0200
+++ b/client/src/lib/mixins.js	Wed Jul 14 20:22:32 2021 +0200
@@ -440,17 +440,36 @@
       if (text.includes("{date}")) {
         text = text.replace("{date}", new Date().toLocaleString(locale2));
       }
+      const shortDate = d => {
+        return (
+          (d.getDate() < 10 ? "0" : "") +
+          d.getDate() +
+          "." +
+          (d.getMonth() + 1 < 10 ? "0" : "") +
+          (d.getMonth() + 1) +
+          "." +
+          d.getFullYear()
+        );
+      };
+      const hasVisibleSurvey =
+        this.soundingInfo &&
+        this.bottleneckForPrint &&
+        this.selectedSurvey &&
+        this.openLayersMap()
+          .getLayer("BOTTLENECKISOLINE")
+          .getVisible();
+      if (text.includes("{surveydate}") && hasVisibleSurvey) {
+        const dateFromSurvey = new Date(this.selectedSurvey["date_info"]);
+        let dt = shortDate(dateFromSurvey);
+        text = text.replace("{surveydate}", dt.toLocaleString(locale2));
+      } else {
+        let dt = shortDate(new Date());
+        text = text.replace("{surveydate}", dt.toLocaleString(locale2));
+      }
       // get only day,month and year from the Date object
       if (text.includes("{date-minor}")) {
         var date = new Date();
-        var dt =
-          (date.getDate() < 10 ? "0" : "") +
-          date.getDate() +
-          "." +
-          (date.getMonth() + 1 < 10 ? "0" : "") +
-          (date.getMonth() + 1) +
-          "." +
-          date.getFullYear();
+        let dt = shortDate(date);
         text = text.replace("{date-minor}", dt.toLocaleString(locale2));
       }
       if (text.includes("{user}")) {
--- a/pkg/controllers/diff.go	Wed Jul 14 16:21:27 2021 +0200
+++ b/pkg/controllers/diff.go	Wed Jul 14 20:22:32 2021 +0200
@@ -251,7 +251,7 @@
 
 	log.Printf("info: z range: %.3f - %.3f\n", zMin, zMax)
 
-	var heights []float64
+	var heights mesh.ClassBreaks
 
 	heights, err = mesh.LoadClassBreaks(
 		ctx, tx,
@@ -261,7 +261,7 @@
 		err = nil
 		heights = mesh.SampleDiffHeights(zMin, zMax, contourStep)
 	} else {
-		heights = mesh.ExtrapolateClassBreaks(heights, zMin, zMax)
+		heights = heights.ExtrapolateClassBreaks(zMin, zMax)
 	}
 
 	heights = common.DedupFloat64s(heights)
--- a/pkg/controllers/srimports.go	Wed Jul 14 16:21:27 2021 +0200
+++ b/pkg/controllers/srimports.go	Wed Jul 14 20:22:32 2021 +0200
@@ -112,17 +112,33 @@
 		sr.NegateZ = &negateZ
 	}
 
+	// Kept this in for compat.
 	if v := req.FormValue("single-beam"); v != "" {
-		var singleBeam bool
+		var surveyType models.SurveyType
 		switch strings.ToLower(v) {
 		case "true", "1", "singlebeam", "single-beam":
-			singleBeam = true
+			surveyType = models.SurveyTypeSingleBeam
 		case "false", "0", "multibeam", "multi-beam":
-			singleBeam = false
+			surveyType = models.SurveyTypeMultiBeam
 		default:
 			return fmt.Errorf("unknown single-beam '%s'", v)
 		}
-		sr.SingleBeam = &singleBeam
+		sr.SurveyType = &surveyType
+	}
+
+	if v := req.FormValue("survey-type"); v != "" {
+		var surveyType models.SurveyType
+		switch strings.ToLower(v) {
+		case "2", "marking":
+			surveyType = models.SurveyTypeMarking
+		case "true", "1", "singlebeam", "single-beam", "single":
+			surveyType = models.SurveyTypeSingleBeam
+		case "false", "0", "multibeam", "multi-beam", "multi":
+			surveyType = models.SurveyTypeMultiBeam
+		default:
+			return fmt.Errorf("unknown survey-type '%s'", v)
+		}
+		sr.SurveyType = &surveyType
 	}
 
 	return nil
--- a/pkg/controllers/surveys.go	Wed Jul 14 16:21:27 2021 +0200
+++ b/pkg/controllers/surveys.go	Wed Jul 14 20:22:32 2021 +0200
@@ -33,7 +33,8 @@
   s.date_info::text,
   s.depth_reference,
   COALESCE(g.objname, 'ERROR: MISSING GAUGE') AS gauge_objname,
-  r.value AS waterlevel_value
+  r.value AS waterlevel_value,
+  COALESCE(s.surtyp, 'ERROR: MISSING SURVEY TYPE') AS surtype
 FROM waterway.bottlenecks AS b
   JOIN waterway.sounding_results AS s ON b.bottleneck_id = s.bottleneck_id
   LEFT JOIN waterway.gauges AS g
@@ -63,6 +64,7 @@
 	// (like done in controllers/search.go)
 	for rows.Next() {
 		var survey models.Survey
+		var surType string
 		var level sql.NullInt64
 		if err = rows.Scan(
 			&survey.BottleneckID,
@@ -70,12 +72,14 @@
 			&survey.DepthReference,
 			&survey.ReferenceGauge,
 			&level,
+			&surType,
 		); err != nil {
 			return
 		}
 		if level.Valid {
 			survey.WaterLevelValue = &level.Int64
 		}
+		survey.SurveyType = models.SurveyType(surType)
 		surveys = append(surveys, &survey)
 	}
 
--- a/pkg/controllers/system.go	Wed Jul 14 16:21:27 2021 +0200
+++ b/pkg/controllers/system.go	Wed Jul 14 20:22:32 2021 +0200
@@ -194,9 +194,13 @@
 		return nil, err
 	}
 
+	styles := strings.Split(which, ",")
+
 	doBoth := func(req *http.Request) {
 		log.Printf("info: Trigger re-calculation of %s.", which)
-		geoserver.ReconfigureStyle(which)
+		for _, style := range styles {
+			geoserver.ReconfigureStyle(style)
+		}
 		recalc(req)
 	}
 
@@ -228,7 +232,11 @@
 	// Only the color changed -> no expensive recalc needed.
 	if colorChanged {
 		log.Println("info: Only colors changed.")
-		return func(*http.Request) { geoserver.ReconfigureStyle(which) }, nil
+		return func(*http.Request) {
+			for _, style := range styles {
+				geoserver.ReconfigureStyle(style)
+			}
+		}, nil
 	}
 
 	return nil, nil
@@ -239,7 +247,7 @@
 		func(old sql.NullString, curr string) (func(*http.Request), error) {
 			return reconfigureClassBreaks(
 				old, curr,
-				"sounding_results_areas_geoserver",
+				"sounding_results_areas_geoserver,sounding_results_marking_points_geoserver",
 				func(req *http.Request) {
 					if s, ok := auth.GetSession(req); ok {
 						triggerSoundingResultsContoursRecalc(s.User, curr)
--- a/pkg/geoserver/boot.go	Wed Jul 14 16:21:27 2021 +0200
+++ b/pkg/geoserver/boot.go	Wed Jul 14 20:22:32 2021 +0200
@@ -308,6 +308,18 @@
 		}
 		if srs := tables[i].SRS; srs != nil {
 			ft["srs"] = *srs
+			// A bit of a hack!
+			if *srs == "EPSG:4326" {
+				box := map[string]interface{}{
+					"minx": -180,
+					"maxx": +180,
+					"miny": -90,
+					"maxy": +90,
+					"crs":  "EPSG:4326",
+				}
+				ft["nativeBoundingBox"] = box
+				ft["latLonBoundingBox"] = box
+			}
 		}
 
 		var entries []map[string]interface{}
--- a/pkg/geoserver/templates.go	Wed Jul 14 16:21:27 2021 +0200
+++ b/pkg/geoserver/templates.go	Wed Jul 14 20:22:32 2021 +0200
@@ -38,6 +38,9 @@
 		"sounding_results_areas_geoserver",
 		templateContourLinesFunc("morphology_classbreaks"))
 	RegisterStylePreprocessor(
+		"sounding_results_marking_points_geoserver",
+		templateContourLinesFunc("morphology_classbreaks"))
+	RegisterStylePreprocessor(
 		"sounding_differences",
 		templateContourLinesFunc("morphology_classbreaks_compare"))
 	RegisterStylePreprocessor(
--- a/pkg/imports/isr.go	Wed Jul 14 16:21:27 2021 +0200
+++ b/pkg/imports/isr.go	Wed Jul 14 20:22:32 2021 +0200
@@ -16,10 +16,11 @@
 import (
 	"context"
 	"database/sql"
+	"log"
 	"time"
 
-	"gemma.intevation.de/gemma/pkg/common"
 	"gemma.intevation.de/gemma/pkg/mesh"
+	"gemma.intevation.de/gemma/pkg/models"
 )
 
 // IsoRefresh is an import job to refresh the pre-calculated
@@ -52,23 +53,40 @@
 
 const (
 	fetchSoundingResultsIDsSQL = `
-SELECT bottleneck_id, id
+SELECT
+  bottleneck_id,
+  id,
+  surtyp
 FROM waterway.sounding_results
-ORDER BY bottleneck_id
-`
+ORDER BY bottleneck_id`
+
 	deleteIsoAreasSQL = `
 DELETE FROM waterway.sounding_results_iso_areas
-WHERE sounding_result_id = $1
-`
+WHERE sounding_result_id = $1`
+
+	fetchMarkingPointsSQL = `
+SELECT ST_AsBinary(points::geometry(MULTIPOINTZ))
+FROM waterway.sounding_results_marking_points
+WHERE sounding_result_id = $1`
+
+	deleteMarkingPointsSQL = `
+DELETE FROM waterway.sounding_results_marking_points
+WHERE sounding_result_id = $1`
 )
 
 // CleanUp of a iso refresh import is a NOP.
 func (isr *IsoRefresh) CleanUp() error { return nil }
 
-type bottleneckSoundingResults struct {
-	bn  string
-	srs []int64
-}
+type (
+	scanResult struct {
+		id         int64
+		surveyType models.SurveyType
+	}
+	bottleneckSoundingResults struct {
+		bn  string
+		srs []scanResult
+	}
+)
 
 func fetchBottleneckResults(
 	ctx context.Context,
@@ -81,33 +99,32 @@
 	}
 	defer rows.Close()
 
-	var ids []bottleneckSoundingResults
+	var bsrs []bottleneckSoundingResults
 
 	for rows.Next() {
-		var bn string
-		var sr int64
-		if err := rows.Scan(&bn, &sr); err != nil {
+		var (
+			bn string
+			id int64
+			st string
+		)
+		if err := rows.Scan(&bn, &id, &st); err != nil {
 			return nil, err
 		}
-		if len(ids) > 0 {
-			if ids[len(ids)-1].bn != bn {
-				ids = append(ids, bottleneckSoundingResults{
-					bn:  bn,
-					srs: []int64{sr},
-				})
-			} else {
-				ids[len(ids)-1].srs = append(ids[len(ids)-1].srs, sr)
-			}
+		sr := scanResult{id: id, surveyType: models.SurveyType(st)}
+
+		if l := len(bsrs); l == 0 || bsrs[l-1].bn != bn {
+			bsrs = append(bsrs, bottleneckSoundingResults{
+				bn:  bn,
+				srs: []scanResult{sr},
+			})
 		} else {
-			ids = []bottleneckSoundingResults{
-				{bn: bn, srs: []int64{sr}},
-			}
+			bsrs[l-1].srs = append(bsrs[l-1].srs, sr)
 		}
 	}
 	if err := rows.Err(); err != nil {
 		return nil, err
 	}
-	return ids, nil
+	return bsrs, nil
 }
 
 // Do executes the actual refreshing of the iso areas.
@@ -131,17 +148,25 @@
 	if err != nil {
 		return nil, err
 	}
+	heights = heights.Dedup()
 
 	bns, err := fetchBottleneckResults(ctx, conn)
 	if err != nil {
 		return nil, err
 	}
 
+	isrs, err := newISRStmts(ctx, conn)
+	if err != nil {
+		return nil, err
+	}
+	defer isrs.close()
+
 	for i := range bns {
 		start := time.Now()
 		feedback.Info("Processing bottleneck '%s' ...", bns[i].bn)
 		err := isr.processBottleneck(
-			ctx, conn,
+			ctx, conn, feedback,
+			isrs,
 			heights,
 			&bns[i],
 		)
@@ -154,10 +179,55 @@
 	return nil, nil
 }
 
+type isrStmts struct {
+	insertAreas         *sql.Stmt
+	deleteAreas         *sql.Stmt
+	fetchMarkingPoints  *sql.Stmt
+	deleteMarkingPoints *sql.Stmt
+	insertMarkingPoints *sql.Stmt
+}
+
+func newISRStmts(ctx context.Context, conn *sql.Conn) (*isrStmts, error) {
+	var isrs isrStmts
+	for _, x := range []struct {
+		stmt  **sql.Stmt
+		query string
+	}{
+		{&isrs.insertAreas, insertIsoAreasSQL},
+		{&isrs.deleteAreas, deleteIsoAreasSQL},
+		{&isrs.fetchMarkingPoints, fetchMarkingPointsSQL},
+		{&isrs.deleteMarkingPoints, deleteMarkingPointsSQL},
+		{&isrs.insertMarkingPoints, insertMarkingPointsSQL},
+	} {
+		var err error
+		if *x.stmt, err = conn.PrepareContext(ctx, x.query); err != nil {
+			isrs.close()
+			return nil, err
+		}
+	}
+	return &isrs, nil
+}
+
+func (isrs *isrStmts) close() {
+	for _, x := range []**sql.Stmt{
+		&isrs.insertAreas,
+		&isrs.deleteAreas,
+		&isrs.fetchMarkingPoints,
+		&isrs.deleteMarkingPoints,
+		&isrs.insertMarkingPoints,
+	} {
+		if *x != nil {
+			(*x).Close()
+		}
+	}
+}
+
 func (isr *IsoRefresh) processBottleneck(
 	ctx context.Context,
 	conn *sql.Conn,
-	heights []float64,
+	feedback Feedback,
+	isrs *isrStmts,
+	heights mesh.ClassBreaks,
 	bn *bottleneckSoundingResults,
 ) error {
 	// Do one transaction per bottleneck.
@@ -167,52 +237,114 @@
 	}
 	defer tx.Rollback()
 
-	insertAreasStmt, err := tx.Prepare(insertIsoAreasSQL)
-	if err != nil {
-		return err
-	}
-	defer insertAreasStmt.Close()
+	var (
+		insertAreas         = tx.StmtContext(ctx, isrs.insertAreas)
+		deleteAreas         = tx.StmtContext(ctx, isrs.deleteAreas)
+		fetchMarkingPoints  = tx.StmtContext(ctx, isrs.fetchMarkingPoints)
+		deleteMarkingPoints = tx.StmtContext(ctx, isrs.deleteMarkingPoints)
+		insertMarkingPoints = tx.StmtContext(ctx, isrs.insertMarkingPoints)
+	)
+
+	var markings, beams int
 
 	// For all sounding results in bottleneck.
 	for _, sr := range bn.srs {
-		tree, err := mesh.FetchMeshDirectly(ctx, tx, sr)
-		if err != nil {
-			return err
-		}
-		hs := mesh.ExtrapolateClassBreaks(heights, tree.Min().Z, tree.Max().Z)
-		hs = common.DedupFloat64s(hs)
+		switch sr.surveyType {
+		case models.SurveyTypeMarking:
+			markings++
+
+			// Read all points back in.
 
-		// Delete the old iso areas.
-		if _, err := tx.ExecContext(ctx, deleteIsoAreasSQL, sr); err != nil {
-			return err
-		}
+			var points mesh.MultiPointZ
+
+			if err := func() error {
+				rows, err := fetchMarkingPoints.QueryContext(ctx, sr.id)
+				if err != nil {
+					return err
+				}
+				defer rows.Close()
 
-		// Calculate and store the iso areas.
-		box := mesh.Box2D{
-			X1: tree.Min().X,
-			Y1: tree.Min().Y,
-			X2: tree.Max().X,
-			Y2: tree.Max().Y,
-		}
+				for rows.Next() {
+					var buf []byte
+					if err := rows.Scan(&buf); err != nil {
+						return err
+					}
+					var npoints mesh.MultiPointZ
+					if err := npoints.FromWKB(buf); err != nil {
+						return err
+					}
+					points = append(points, npoints...)
+				}
+				return rows.Err()
+			}(); err != nil {
+				return err
+			}
 
-		raster := mesh.NewRaster(box, isoCellSize)
-		raster.Rasterize(tree.Value)
-		areas := raster.Trace(hs)
-
-		for i, a := range areas {
-			if len(a) == 0 {
-				continue
-			}
-			if _, err := insertAreasStmt.ExecContext(
-				ctx,
-				sr, hs[i], tree.EPSG(),
-				a.AsWKB(),
-				contourTolerance,
-			); err != nil {
+			// Delete old points
+			if _, err := deleteMarkingPoints.ExecContext(ctx, sr.id); err != nil {
 				return err
 			}
+
+			// Re-classify points.
+			classes := heights.Classify(points)
+
+			// Re-insert points
+			for i, class := range classes {
+				// Ignore empty classes
+				if len(class) == 0 {
+					continue
+				}
+				if _, err := insertMarkingPoints.ExecContext(
+					ctx, sr.id, heights[i], 4326, class.AsWKB(),
+				); err != nil {
+					return err
+				}
+			}
+
+		case models.SurveyTypeMultiBeam, models.SurveyTypeSingleBeam:
+			beams++
+
+			tree, err := mesh.FetchMeshDirectly(ctx, tx, sr.id)
+			if err != nil {
+				return err
+			}
+			hs := heights.ExtrapolateClassBreaks(tree.Min().Z, tree.Max().Z).Dedup()
+
+			// Delete the old iso areas.
+			if _, err := deleteAreas.ExecContext(ctx, sr.id); err != nil {
+				return err
+			}
+
+			// Calculate and store the iso areas.
+			box := mesh.Box2D{
+				X1: tree.Min().X,
+				Y1: tree.Min().Y,
+				X2: tree.Max().X,
+				Y2: tree.Max().Y,
+			}
+
+			raster := mesh.NewRaster(box, isoCellSize)
+			raster.Rasterize(tree.Value)
+			areas := raster.Trace(hs)
+
+			for i, a := range areas {
+				if len(a) == 0 {
+					continue
+				}
+				if _, err := insertAreas.ExecContext(
+					ctx,
+					sr.id, hs[i], tree.EPSG(),
+					a.AsWKB(),
+					contourTolerance,
+				); err != nil {
+					return err
+				}
+			}
+		default:
+			log.Printf("error: unknown survey type '%s'\n", sr.surveyType)
 		}
 	}
+	feedback.Info("Scan types: Single/Multi: %d Marking: %d", beams, markings)
 
 	return tx.Commit()
 }
--- a/pkg/imports/sr.go	Wed Jul 14 16:21:27 2021 +0200
+++ b/pkg/imports/sr.go	Wed Jul 14 20:22:32 2021 +0200
@@ -58,8 +58,10 @@
 	// DepthReference if given overides the DepthReference value
 	// from the meta.json.
 	DepthReference *string `json:"depth-reference,omitempty"`
-	// SingleBeam indicates that the sounding is a single beam scan.
+	// SingleBeam is kept in for compat.
 	SingleBeam *bool `json:"single-beam,omitempty"`
+	// SurveyType indicates that the sounding is a single beam scan.
+	SurveyType *models.SurveyType `json:"survey-type,omitempty"`
 	// NegateZ indicated that the Z values of thy XYZ input should be
 	// multiplied by -1.
 	NegateZ *bool `json:"negate-z,omitempty"`
@@ -104,7 +106,8 @@
 
 func (srJobCreator) Depends() [2][]string {
 	return [2][]string{
-		{"sounding_results", "sounding_results_iso_areas"},
+		{"sounding_results", "sounding_results_iso_areas",
+			"sounding_results_marking_points"},
 		{"bottlenecks"},
 	}
 }
@@ -195,6 +198,19 @@
 FROM waterway.sounding_results sr
 WHERE id = $1
 `
+	insertMarkingPointsSQL = `
+INSERT INTO waterway.sounding_results_marking_points (
+  sounding_result_id,
+  height,
+  points
+)
+SELECT
+  $1,
+  $2,
+  ST_Transform(ST_GeomFromWKB($4, $3::integer), 4326)
+FROM waterway.sounding_results sr
+WHERE id = $1
+`
 
 	selectGaugeLDCSQL = `
 SELECT
@@ -237,18 +253,21 @@
 	if sr.NegateZ != nil && *sr.NegateZ {
 		descs = append(descs, "negateZ")
 	}
+	if sr.surveyType != nil {
+		descs = append(descs, string(*sr.SurveyType))
+	}
 	return strings.Join(descs, "|"), nil
 }
 
-func (sr *SoundingResult) singleBeam() bool {
-	return sr.SingleBeam != nil && *sr.SingleBeam
+func (sr *SoundingResult) surveyType() models.SurveyType {
+	if sr.SurveyType != nil {
+		return *sr.SurveyType
+	}
+	return models.SurveyTypeMultiBeam
 }
 
 func (sr *SoundingResult) surtype() string {
-	if sr.singleBeam() {
-		return "single"
-	}
-	return "multi"
+	return string(sr.surveyType())
 }
 
 func (sr *SoundingResult) negateZ() bool {
@@ -419,24 +438,19 @@
 	zpgException bool,
 ) (interface{}, error) {
 
-	if sr.singleBeam() {
-		feedback.Info("Processing as single beam scan.")
-	} else {
-		feedback.Info("Processing as multi beam scan.")
-	}
+	feedback.Info("Processing as %s beam scan.", sr.surtype())
 
 	feedback.Info("Reproject XYZ data.")
 
 	start := time.Now()
 
-	xyzWKB := xyz.AsWKB()
 	var reproj []byte
 	var epsg uint32
 
 	if err := tx.QueryRowContext(
 		ctx,
 		reprojectPointsSingleBeamSQL,
-		xyzWKB,
+		xyz.AsWKB(),
 		m.EPSG,
 	).Scan(&reproj, &epsg); err != nil {
 		return nil, err
@@ -465,7 +479,6 @@
 		removed                 map[int32]struct{}
 		polygonArea             float64
 		clippingPolygonWKB      []byte
-		tin                     *mesh.Tin
 	)
 
 	if boundary == nil {
@@ -532,7 +545,7 @@
 		removed = str.Clip(&clippingPolygon)
 	}
 
-	if sr.singleBeam() {
+	if sr.surveyType() == models.SurveyTypeSingleBeam {
 
 		origDensity := float64(len(xyz)) / polygonArea
 
@@ -604,27 +617,41 @@
 
 multibeam:
 
-	start = time.Now()
-	tin = tri.Tin()
-	tin.EPSG = epsg
+	final := mesh.STRTree{Entries: 16}
+
+	if sr.surveyType() != models.SurveyTypeMarking {
 
-	var str mesh.STRTree
-	str.Build(tin)
-	feedback.Info("Building clipping index took %v", time.Since(start))
+		start = time.Now()
+		tin := tri.Tin()
+		tin.EPSG = epsg
 
-	start = time.Now()
+		var str mesh.STRTree
+		str.Build(tin)
+		feedback.Info("Building clipping index took %v", time.Since(start))
+
+		start = time.Now()
 
-	clippingPolygonBuffered.Indexify()
-	removed = str.Clip(&clippingPolygonBuffered)
-	feedback.Info("Clipping took %v.", time.Since(start))
-	feedback.Info("Number of triangles to clip %d.", len(removed))
+		clippingPolygonBuffered.Indexify()
+		removed = str.Clip(&clippingPolygonBuffered)
+		feedback.Info("Clipping took %v.", time.Since(start))
+		feedback.Info("Number of triangles to clip %d.", len(removed))
+
+		start = time.Now()
+		final.BuildWithout(tin, removed)
 
-	start = time.Now()
-	final := mesh.STRTree{Entries: 16}
-	final.BuildWithout(tin, removed)
-
-	feedback.Info("Building final mesh took %v.", time.Since(start))
-	feedback.Info("Store mesh.")
+		feedback.Info("Building final mesh took %v.", time.Since(start))
+		feedback.Info("Store mesh.")
+	} else {
+		start = time.Now()
+		clippingPolygonBuffered.Indexify()
+		before := len(xyz)
+		xyz = xyz.Filter(func(v mesh.Vertex) bool {
+			return clippingPolygonBuffered.IntersectionBox2D(v.Box2D()) !=
+				mesh.IntersectionOutSide
+		})
+		feedback.Info("Clipping took %v.", time.Since(start))
+		feedback.Info("Number of points to clip: %d.", before-len(xyz))
+	}
 
 	start = time.Now()
 
@@ -663,20 +690,32 @@
 		return nil, err
 	}
 
-	index, err := final.Bytes()
-	if err != nil {
-		return nil, err
-	}
+	if sr.surveyType() != models.SurveyTypeMarking {
+
+		index, err := final.Bytes()
+		if err != nil {
+			return nil, err
+		}
 
-	h := sha1.New()
-	h.Write(index)
-	checksum := hex.EncodeToString(h.Sum(nil))
-	_, err = tx.ExecContext(ctx, insertMeshSQL, id, checksum, index)
-	if err != nil {
-		return nil, err
+		h := sha1.New()
+		h.Write(index)
+		checksum := hex.EncodeToString(h.Sum(nil))
+		_, err = tx.ExecContext(ctx, insertMeshSQL, id, checksum, index)
+		if err != nil {
+			return nil, err
+		}
+		feedback.Info("Storing mesh index took %s.", time.Since(start))
+		err = generateIsoAreas(
+			ctx, tx, feedback,
+			&final,
+			loadClassBreaks(ctx, tx, feedback, final.Min().Z, final.Max().Z),
+			id)
+	} else { // SurveyTypeMarking
+		err = generateMarkingPoints(
+			ctx, tx, feedback,
+			xyz, epsg,
+			id)
 	}
-	feedback.Info("Storing mesh index took %s.", time.Since(start))
-	err = generateIsos(ctx, tx, feedback, &final, id)
 	if err != nil {
 		return nil, err
 	}
@@ -712,7 +751,7 @@
 	return sr.Bottleneck != nil &&
 		sr.Date != nil &&
 		sr.DepthReference != nil &&
-		sr.SingleBeam != nil &&
+		sr.SurveyType != nil &&
 		sr.NegateZ != nil
 }
 
@@ -729,7 +768,7 @@
 			Bottleneck:     *sr.Bottleneck,
 			EPSG:           epsg,
 			DepthReference: *sr.DepthReference,
-			SingleBeam:     sr.singleBeam(),
+			SurveyType:     sr.surveyType(),
 			NegateZ:        sr.negateZ(),
 		}, nil
 	}
@@ -756,8 +795,18 @@
 	if sr.DepthReference != nil {
 		m.DepthReference = *sr.DepthReference
 	}
+
+	// Kept in for compat
 	if sr.SingleBeam != nil {
-		m.SingleBeam = *sr.SingleBeam
+		if *sr.SingleBeam {
+			m.SurveyType = models.SurveyTypeSingleBeam
+		} else {
+			m.SurveyType = models.SurveyTypeSingleBeam
+		}
+	}
+
+	if sr.SurveyType != nil {
+		m.SurveyType = *sr.SurveyType
 	}
 	if sr.NegateZ != nil {
 		m.NegateZ = *sr.NegateZ
@@ -879,43 +928,79 @@
 	return shapeToPolygon(s)
 }
 
-func generateIsos(
+func defaultClassBreaks(min, max float64) mesh.ClassBreaks {
+	var heights mesh.ClassBreaks
+	h := contourStepWidth * math.Ceil(min/contourStepWidth)
+	for ; h <= max; h += contourStepWidth {
+		heights = append(heights, h)
+	}
+	return heights
+}
+
+func generateMarkingPoints(
 	ctx context.Context,
 	tx *sql.Tx,
 	feedback Feedback,
-	tree *mesh.STRTree,
+	xyz mesh.MultiPointZ,
+	epsg uint32,
 	id int64,
 ) error {
-
 	heights, err := mesh.LoadClassBreaks(
 		ctx, tx,
-		"morphology_classbreaks",
-	)
-
-	minZ, maxZ := tree.Min().Z, tree.Max().Z
+		"morphology_classbreaks")
 
 	if err != nil {
 		feedback.Warn("Loading class breaks failed: %v", err)
 		feedback.Info("Using default class breaks")
-		heights = nil
-		h := contourStepWidth * math.Ceil(minZ/contourStepWidth)
-		for ; h <= maxZ; h += contourStepWidth {
-			heights = append(heights, h)
-		}
-	} else {
-		heights = mesh.ExtrapolateClassBreaks(heights, minZ, maxZ)
+		min, max := xyz.MinMax()
+		heights = defaultClassBreaks(min.Z, max.Z)
 	}
 
-	/*
-		for i, v := range heights {
-			fmt.Printf("%d %.2f\n", i, v)
+	heights = heights.Dedup()
+
+	classes := heights.Classify(xyz)
+
+	stmt, err := tx.PrepareContext(ctx, insertMarkingPointsSQL)
+	if err != nil {
+		return err
+	}
+	defer stmt.Close()
+
+	for i, class := range classes {
+		// Ignore empty classes
+		if len(class) == 0 {
+			continue
+		}
+		if _, err := stmt.ExecContext(
+			ctx, id, heights[i], epsg, class.AsWKB(),
+		); err != nil {
+			return err
 		}
-		log.Printf("%.2f - %.2f\n", tree.Min.Z, tree.Max.Z)
-	*/
+	}
+
+	return nil
+}
+
+func loadClassBreaks(
+	ctx context.Context,
+	tx *sql.Tx,
+	feedback Feedback,
+	minZ, maxZ float64,
+) mesh.ClassBreaks {
 
-	heights = common.DedupFloat64s(heights)
+	heights, err := mesh.LoadClassBreaks(
+		ctx, tx,
+		"morphology_classbreaks")
 
-	return generateIsoAreas(ctx, tx, feedback, tree, heights, id)
+	if err != nil {
+		feedback.Warn("Loading class breaks failed: %v", err)
+		feedback.Info("Using default class breaks")
+		heights = defaultClassBreaks(minZ, maxZ)
+	} else {
+		heights = heights.ExtrapolateClassBreaks(minZ, maxZ)
+	}
+
+	return heights.Dedup()
 }
 
 func generateIsoAreas(
@@ -923,7 +1008,7 @@
 	tx *sql.Tx,
 	feedback Feedback,
 	tree *mesh.STRTree,
-	heights []float64,
+	heights mesh.ClassBreaks,
 	id int64,
 ) error {
 	feedback.Info("Generate iso areas")
@@ -955,7 +1040,7 @@
 	feedback Feedback,
 	areas []wkb.MultiPolygonGeom,
 	epsg uint32,
-	heights []float64,
+	heights mesh.ClassBreaks,
 	id int64,
 ) error {
 	feedback.Info("Store iso areas.")
--- a/pkg/mesh/classbreaks.go	Wed Jul 14 16:21:27 2021 +0200
+++ b/pkg/mesh/classbreaks.go	Wed Jul 14 20:22:32 2021 +0200
@@ -21,6 +21,8 @@
 	"sort"
 	"strconv"
 	"strings"
+
+	"gemma.intevation.de/gemma/pkg/common"
 )
 
 const (
@@ -29,8 +31,10 @@
 WHERE config_key = $1`
 )
 
-func SampleDiffHeights(min, max, step float64) []float64 {
-	var heights []float64
+type ClassBreaks []float64
+
+func SampleDiffHeights(min, max, step float64) ClassBreaks {
+	var heights ClassBreaks
 	switch {
 	case min >= 0: // All values positive.
 		for v := 0.0; v <= max; v += step {
@@ -58,10 +62,10 @@
 	return heights
 }
 
-func ParseClassBreaks(config string) ([]float64, error) {
+func ParseClassBreaks(config string) (ClassBreaks, error) {
 
 	parts := strings.Split(config, ",")
-	classes := make([]float64, 0, len(parts))
+	classes := make(ClassBreaks, 0, len(parts))
 	for _, part := range parts {
 		if idx := strings.IndexRune(part, ':'); idx >= 0 {
 			part = part[:idx]
@@ -80,7 +84,7 @@
 	return classes, nil
 }
 
-func LoadClassBreaks(ctx context.Context, tx *sql.Tx, key string) ([]float64, error) {
+func LoadClassBreaks(ctx context.Context, tx *sql.Tx, key string) (ClassBreaks, error) {
 
 	var config sql.NullString
 
@@ -102,12 +106,12 @@
 	return math.Round(v*10000) / 10000
 }
 
-func ExtrapolateClassBreaks(cbs []float64, min, max float64) []float64 {
+func (cbs ClassBreaks) ExtrapolateClassBreaks(min, max float64) ClassBreaks {
 	if min > max {
 		min, max = max, min
 	}
 
-	n := make([]float64, len(cbs))
+	n := make(ClassBreaks, len(cbs))
 	copy(n, cbs)
 	sort.Float64s(n)
 
@@ -148,3 +152,26 @@
 
 	return n
 }
+
+func (cbs ClassBreaks) Dedup() ClassBreaks {
+	return ClassBreaks(common.DedupFloat64s(cbs))
+}
+
+func (cbs ClassBreaks) Classify(points MultiPointZ) []MultiPointZ {
+	if len(cbs) == 0 {
+		return nil
+	}
+	classes := make([]MultiPointZ, len(cbs))
+	for _, v := range points {
+		// Place in last class if greater than all.
+		idx := len(cbs) - 1
+		for i, cb := range cbs {
+			if v.Z <= cb {
+				idx = i
+				break
+			}
+		}
+		classes[idx] = append(classes[idx], v)
+	}
+	return classes
+}
--- a/pkg/mesh/raster.go	Wed Jul 14 16:21:27 2021 +0200
+++ b/pkg/mesh/raster.go	Wed Jul 14 20:22:32 2021 +0200
@@ -195,7 +195,7 @@
 	return min, max, min != math.MaxFloat64
 }
 
-func (r *Raster) Trace(heights []float64) []wkb.MultiPolygonGeom {
+func (r *Raster) Trace(heights ClassBreaks) []wkb.MultiPolygonGeom {
 	start := time.Now()
 
 	tracer := contourmap.FromFloat64s(r.XCells+2, r.YCells+2, r.Cells)
--- a/pkg/mesh/vertex.go	Wed Jul 14 16:21:27 2021 +0200
+++ b/pkg/mesh/vertex.go	Wed Jul 14 20:22:32 2021 +0200
@@ -163,6 +163,16 @@
 	return math.Sqrt(v.Dot(v))
 }
 
+// Box2D constructs a Box2D of this vertex.
+func (v Vertex) Box2D() Box2D {
+	return Box2D{
+		X1: v.X,
+		Y1: v.Y,
+		X2: v.X,
+		Y2: v.Y,
+	}
+}
+
 func area(a, b, c Vertex) float64 {
 	return (b.Y-a.Y)*(c.X-b.X) - (b.X-a.X)*(c.Y-b.Y)
 }
@@ -1134,6 +1144,29 @@
 	return out
 }
 
+// Filter returns a copy removed the vertices which
+// don't pass the filter test.
+func (mpz MultiPointZ) Filter(filter func(Vertex) bool) MultiPointZ {
+	n := make(MultiPointZ, 0, len(mpz))
+	for _, v := range mpz {
+		if filter(v) {
+			n = append(n, v)
+		}
+	}
+	return n
+}
+
+// MinMaxVertex returns the extend of the point set.
+func (mpz MultiPointZ) MinMax() (Vertex, Vertex) {
+	min := Vertex{math.MaxFloat64, math.MaxFloat64, math.MaxFloat64}
+	max := Vertex{-math.MaxFloat64, -math.MaxFloat64, -math.MaxFloat64}
+	for _, v := range mpz {
+		min.Minimize(v)
+		max.Maximize(v)
+	}
+	return min, max
+}
+
 // AsWKB returns a WKB representation of the given point cloud.
 func (mpz MultiPointZ) AsWKB() []byte {
 	size := 1 + 4 + 4 + len(mpz)*(1+4+3*8)
--- a/pkg/models/sr.go	Wed Jul 14 16:21:27 2021 +0200
+++ b/pkg/models/sr.go	Wed Jul 14 20:22:32 2021 +0200
@@ -25,17 +25,40 @@
 	"gemma.intevation.de/gemma/pkg/common"
 )
 
+type SurveyType string
+
+const (
+	SurveyTypeMultiBeam  = SurveyType("multi")
+	SurveyTypeSingleBeam = SurveyType("single")
+	SurveyTypeMarking    = SurveyType("marking")
+)
+
 type (
 	SoundingResultMeta struct {
-		Date           Date   `json:"date"`
-		Bottleneck     string `json:"bottleneck"`
-		EPSG           uint   `json:"epsg"`
-		DepthReference string `json:"depth-reference"`
-		SingleBeam     bool   `json:"single-beam"`
-		NegateZ        bool   `json:"negate-z,omitempty"`
+		Date           Date       `json:"date"`
+		Bottleneck     string     `json:"bottleneck"`
+		EPSG           uint       `json:"epsg"`
+		DepthReference string     `json:"depth-reference"`
+		SingleBeam     *bool      `json:"single-beam,omitempty"` // kept in for compat!
+		SurveyType     SurveyType `json:"survey-type,omitempty"`
+		NegateZ        bool       `json:"negate-z,omitempty"`
 	}
 )
 
+func (st *SurveyType) UnmarshalJSON(data []byte) error {
+	var s string
+	if err := json.Unmarshal(data, &s); err != nil {
+		return err
+	}
+	switch x := SurveyType(s); x {
+	case SurveyTypeMultiBeam, SurveyTypeSingleBeam, SurveyTypeMarking:
+		*st = x
+		return nil
+	default:
+		return fmt.Errorf("unkown survey type '%s'", s)
+	}
+}
+
 const (
 	checkDepthReferenceSQL = `
 SELECT EXISTS(SELECT 1
@@ -57,11 +80,37 @@
 )
 
 func (m *SoundingResultMeta) Decode(r io.Reader) error {
-	err := json.NewDecoder(r).Decode(m)
-	if err == nil && m.EPSG == 0 {
+	if err := json.NewDecoder(r).Decode(m); err != nil {
+		return err
+	}
+
+	if m.EPSG == 0 {
 		m.EPSG = WGS84
 	}
-	return err
+
+	if m.SingleBeam != nil {
+		// Check if single-beam and survey-type match.
+		if m.SurveyType != "" {
+			if (*m.SingleBeam && m.SurveyType != SurveyTypeSingleBeam) ||
+				(!*m.SingleBeam && m.SurveyType != SurveyTypeMultiBeam) {
+				return errors.New("'single-beam' and 'survey-type' mismatch")
+			}
+		} else { // Only single-beam given
+			if *m.SingleBeam {
+				m.SurveyType = SurveyTypeSingleBeam
+			} else {
+				m.SurveyType = SurveyTypeMultiBeam
+			}
+		}
+		// Kill single-beam
+		m.SingleBeam = nil
+	}
+
+	if m.SurveyType == "" { // default to multi-beam
+		m.SurveyType = SurveyTypeMultiBeam
+	}
+
+	return nil
 }
 
 func (m *SoundingResultMeta) Validate(ctx context.Context, conn *sql.Conn) []error {
--- a/pkg/models/surveys.go	Wed Jul 14 16:21:27 2021 +0200
+++ b/pkg/models/surveys.go	Wed Jul 14 20:22:32 2021 +0200
@@ -15,10 +15,11 @@
 
 type (
 	Survey struct {
-		BottleneckID    string `json:"bottleneck_id"`
-		DateInfo        string `json:"date_info"`
-		DepthReference  string `json:"depth_reference"`
-		ReferenceGauge  string `json:"gauge_objname"`
-		WaterLevelValue *int64 `json:"waterlevel_value,omitempty"`
+		BottleneckID    string     `json:"bottleneck_id"`
+		DateInfo        string     `json:"date_info"`
+		DepthReference  string     `json:"depth_reference"`
+		ReferenceGauge  string     `json:"gauge_objname"`
+		SurveyType      SurveyType `json:"survey_type"`
+		WaterLevelValue *int64     `json:"waterlevel_value,omitempty"`
 	}
 )
--- a/report-templates/data-quality-report.yaml	Wed Jul 14 16:21:27 2021 +0200
+++ b/report-templates/data-quality-report.yaml	Wed Jul 14 20:22:32 2021 +0200
@@ -34,9 +34,11 @@
     - type: select
       statement: >
         SELECT to_char(d, 'Month YYYY') AS month, d::date
-          FROM generate_series( '2019-10-01'::date,
-                                now() - interval '1 day',
-                                '1 month'::interval ) d;
+          FROM generate_series( date_trunc('month',
+                                           now() - interval '1 day'
+                                          )::date,
+                                '2019-10-01'::date,
+                                - '1 month'::interval ) d;
       actions:
       - type: assign
         name: column_number
@@ -89,9 +91,11 @@
     - type: select
       statement: >
         SELECT to_char(d, 'Month YYYY') AS month, d::date
-          FROM generate_series( '2019-10-01'::date,
-                                now() - interval '1 day',
-                                '1 month'::interval ) d;
+          FROM generate_series( date_trunc('month',
+                                           now() - interval '1 day'
+                                          )::date,
+                                '2019-10-01'::date,
+                                - '1 month'::interval ) d;
       actions:
       - type: assign
         name: column_number
--- a/schema/default_sysconfig.sql	Wed Jul 14 16:21:27 2021 +0200
+++ b/schema/default_sysconfig.sql	Wed Jul 14 20:22:32 2021 +0200
@@ -218,6 +218,16 @@
         FROM waterway.sounding_results_iso_areas ia
             JOIN waterway.sounding_results sr ON sr.id = ia.sounding_result_id
     $$),
+    ('waterway', 'sounding_results_marking_points_geoserver', 4326, NULL, $$
+        SELECT bottleneck_id,
+            to_char(date_info, 'YYYY-MM-DD') AS date_info,
+            height,
+            points::geometry(MULTIPOINTZ, 4326),
+            surtyp,
+            zpg_exception
+        FROM waterway.sounding_results_marking_points mp
+            JOIN waterway.sounding_results sr ON sr.id = mp.sounding_result_id
+    $$),
     ('waterway', 'bottleneck_overview', 4326, NULL, $$
         SELECT
             objnam AS name,
@@ -400,7 +410,9 @@
 --
 -- group layers
 --
-INSERT INTO sys_admin.layer_groups VALUES ('fairway_marks');
+INSERT INTO sys_admin.layer_groups VALUES
+  ('fairway_marks'),
+  ('sounding_results');
 
 INSERT INTO sys_admin.grouped_layers VALUES
   ('fairway_marks', 'waterway', 'fairway_marks_boylat_hydro', 0),
@@ -415,7 +427,9 @@
   ('fairway_marks', 'waterway', 'fairway_marks_lights', 6),
   ('fairway_marks', 'waterway', 'fairway_marks_rtpbcn', 7),
   ('fairway_marks', 'waterway', 'fairway_marks_topmar', 8),
-  ('fairway_marks', 'waterway', 'fairway_marks_notmrk', 9);
+  ('fairway_marks', 'waterway', 'fairway_marks_notmrk', 9),
+  ('sounding_results', 'waterway', 'sounding_results_areas_geoserver', 0),
+  ('sounding_results', 'waterway', 'sounding_results_marking_points_geoserver', 1);
 
 --
 -- Settings
--- a/schema/gemma.sql	Wed Jul 14 16:21:27 2021 +0200
+++ b/schema/gemma.sql	Wed Jul 14 20:22:32 2021 +0200
@@ -353,7 +353,7 @@
     survey_type varchar PRIMARY KEY
 );
 
-INSERT INTO survey_types (survey_type) VALUES ('single'), ('multi');
+INSERT INTO survey_types (survey_type) VALUES ('single'), ('multi'), ('marking');
 
 CREATE TABLE coverage_types (
     coverage_type varchar PRIMARY KEY
@@ -828,6 +828,18 @@
             -- CHECK(ST_IsSimple(CAST(areas AS geometry))),
         PRIMARY KEY (sounding_result_id, height)
     )
+
+    CREATE TABLE sounding_results_marking_points (
+        sounding_result_id int NOT NULL REFERENCES sounding_results
+          ON DELETE CASCADE,
+        height numeric NOT NULL,
+        -- XXX: GeoServer does not like geography(MULTIPOINTZ)
+        --      We need to track this down. Maybe with
+        --      GeoServer upstream.
+        points geography(MULTIPOINTZ, 4326) NOT NULL,
+        PRIMARY KEY (sounding_result_id, height)
+    )
+
     --
     -- Fairway availability
     --
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/schema/updates/1460/01.markings.sql	Wed Jul 14 20:22:32 2021 +0200
@@ -0,0 +1,15 @@
+INSERT INTO survey_types (survey_type) VALUES ('marking');
+
+CREATE TABLE waterway.sounding_results_marking_points (
+   sounding_result_id int NOT NULL REFERENCES waterway.sounding_results
+     ON DELETE CASCADE,
+   height numeric NOT NULL,
+   points geography(MULTIPOINTZ, 4326) NOT NULL,
+   PRIMARY KEY (sounding_result_id, height)
+);
+
+GRANT INSERT, UPDATE, DELETE ON waterway.sounding_results_marking_points
+  TO waterway_admin;
+
+GRANT SELECT ON waterway.sounding_results_marking_points
+  TO waterway_user;
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/schema/updates/1461/01.config.sql	Wed Jul 14 20:22:32 2021 +0200
@@ -0,0 +1,15 @@
+-- GeoServer SQL views without time support
+INSERT INTO sys_admin.published_services (
+    schema, name, srid, key_column, view_def
+) VALUES
+    ('waterway', 'sounding_results_marking_points_geoserver', 4326, NULL, $$
+        SELECT bottleneck_id,
+            to_char(date_info, 'YYYY-MM-DD') AS date_info,
+            height,
+            points,
+            surtyp,
+            zpg_exception
+        FROM waterway.sounding_results_marking_points mp
+            JOIN waterway.sounding_results sr ON sr.id = mp.sounding_result_id
+    $$);
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/schema/updates/1462/01.refine_marking_view.sql	Wed Jul 14 20:22:32 2021 +0200
@@ -0,0 +1,11 @@
+UPDATE sys_admin.published_services SET view_def = $$
+SELECT bottleneck_id,
+  to_char(date_info, 'YYYY-MM-DD') AS date_info,
+  height,
+  points::geometry(multipointz, 4326) as points,
+  surtyp,
+  zpg_exception
+FROM waterway.sounding_results_marking_points mp
+  JOIN waterway.sounding_results sr ON sr.id = mp.sounding_result_id
+$$
+WHERE name = 'sounding_results_marking_points_geoserver';
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/schema/updates/1463/01.sounding_result_group_layer.sql	Wed Jul 14 20:22:32 2021 +0200
@@ -0,0 +1,6 @@
+INSERT INTO sys_admin.layer_groups VALUES
+  ('sounding_results');
+
+INSERT INTO sys_admin.grouped_layers VALUES
+  ('sounding_results', 'waterway', 'sounding_results_areas_geoserver', 0),
+  ('sounding_results', 'waterway', 'sounding_results_marking_points_geoserver', 1);
--- a/schema/version.sql	Wed Jul 14 16:21:27 2021 +0200
+++ b/schema/version.sql	Wed Jul 14 20:22:32 2021 +0200
@@ -1,1 +1,1 @@
-INSERT INTO gemma_schema_version(version) VALUES (1454);
+INSERT INTO gemma_schema_version(version) VALUES (1463);
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/style-templates/sounding_results_marking_points_geoserver.sld-template	Wed Jul 14 20:22:32 2021 +0200
@@ -0,0 +1,275 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<StyledLayerDescriptor
+    xmlns="http://www.opengis.net/sld"
+    xmlns:se="http://www.opengis.net/se"
+    xmlns:ogc="http://www.opengis.net/ogc"
+    xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+    xsi:schemaLocation="http://www.opengis.net/sld http://schemas.opengis.net/sld/1.1.0/StyledLayerDescriptor.xsd"
+    version="1.1.0">
+  <NamedLayer>
+    <se:Name>sounding_results_areas</se:Name>
+    <UserStyle>
+      <se:Name>sounding_results_areas</se:Name>
+      <se:FeatureTypeStyle>
+          <se:Name>area_colours</se:Name>
+        <se:Description>
+          <se:Abstract>
+            FeatureTypeStyle defining colour classes for height attribute
+          </se:Abstract>
+        </se:Description>
+        {{ range . -}}
+        <se:Rule>
+        {{- if not .HasLow }}
+          <se:Name>&#8804; {{ printf "%g" .High }}</se:Name>
+          <ogc:Filter>
+            <ogc:PropertyIsLessThanOrEqualTo>
+              <ogc:PropertyName>height</ogc:PropertyName>
+              <ogc:Literal>{{ printf "%f" .High }}</ogc:Literal>
+            </ogc:PropertyIsLessThanOrEqualTo>
+          </ogc:Filter>
+        {{- else if not .HasHigh }}
+          <se:Name>&gt; {{ printf "%g" .Low }}</se:Name>
+          <ogc:Filter>
+            <ogc:PropertyIsGreaterThanOrEqualTo>
+              <ogc:PropertyName>height</ogc:PropertyName>
+              <ogc:Literal>{{ printf "%f" .Low }}</ogc:Literal>
+            </ogc:PropertyIsGreaterThanOrEqualTo>
+          </ogc:Filter>
+        {{- else }}
+          <se:Name>&#8804; {{ printf "%g" .High }}</se:Name>
+          <ogc:Filter>
+            <ogc:And>
+              <ogc:PropertyIsGreaterThan>
+                <ogc:PropertyName>height</ogc:PropertyName>
+                <ogc:Literal>{{ printf "%f" .Low }}</ogc:Literal>
+              </ogc:PropertyIsGreaterThan>
+              <ogc:PropertyIsLessThanOrEqualTo>
+                <ogc:PropertyName>height</ogc:PropertyName>
+                <ogc:Literal>{{ printf "%f" .High }}</ogc:Literal>
+              </ogc:PropertyIsLessThanOrEqualTo>
+            </ogc:And>
+          </ogc:Filter>
+        {{- end }}
+          <se:MaxScaleDenominator>34e3</se:MaxScaleDenominator>
+          <se:PointSymbolizer>
+            <se:Graphic>
+              <se:Mark>
+                <se:WellKnownName>circle</se:WellKnownName>
+                <se:Fill>
+                  <se:SvgParameter name="fill">{{ .Color }}</se:SvgParameter>
+                </se:Fill>
+              </se:Mark>
+              <se:Size>6</se:Size>
+            </se:Graphic>
+          </se:PointSymbolizer>
+        </se:Rule>
+        <se:Rule>
+        {{- if not .HasLow }}
+          <se:Name>&#8804; {{ printf "%g" .High }}</se:Name>
+          <ogc:Filter>
+            <ogc:PropertyIsLessThanOrEqualTo>
+              <ogc:PropertyName>height</ogc:PropertyName>
+              <ogc:Literal>{{ printf "%f" .High }}</ogc:Literal>
+            </ogc:PropertyIsLessThanOrEqualTo>
+          </ogc:Filter>
+        {{- else if not .HasHigh }}
+          <se:Name>&gt; {{ printf "%g" .Low }}</se:Name>
+          <ogc:Filter>
+            <ogc:PropertyIsGreaterThanOrEqualTo>
+              <ogc:PropertyName>height</ogc:PropertyName>
+              <ogc:Literal>{{ printf "%f" .Low }}</ogc:Literal>
+            </ogc:PropertyIsGreaterThanOrEqualTo>
+          </ogc:Filter>
+        {{- else }}
+          <se:Name>&#8804; {{ printf "%g" .High }}</se:Name>
+          <ogc:Filter>
+            <ogc:And>
+              <ogc:PropertyIsGreaterThan>
+                <ogc:PropertyName>height</ogc:PropertyName>
+                <ogc:Literal>{{ printf "%f" .Low }}</ogc:Literal>
+              </ogc:PropertyIsGreaterThan>
+              <ogc:PropertyIsLessThanOrEqualTo>
+                <ogc:PropertyName>height</ogc:PropertyName>
+                <ogc:Literal>{{ printf "%f" .High }}</ogc:Literal>
+              </ogc:PropertyIsLessThanOrEqualTo>
+            </ogc:And>
+          </ogc:Filter>
+        {{- end }}
+          <se:MinScaleDenominator>34e3</se:MinScaleDenominator>
+          <se:PointSymbolizer>
+            <se:Graphic>
+              <se:Mark>
+                <se:WellKnownName>circle</se:WellKnownName>
+                <se:Fill>
+                  <se:SvgParameter name="fill">{{ .Color }}</se:SvgParameter>
+                </se:Fill>
+              </se:Mark>
+              <se:Size>6</se:Size>
+            </se:Graphic>
+          </se:PointSymbolizer>
+        </se:Rule>
+        <se:VendorOption name="sortBy">height</se:VendorOption>
+        {{ end }}
+      </se:FeatureTypeStyle>
+      <se:FeatureTypeStyle>
+        <se:Name>area_labels</se:Name>
+        <se:Description>
+          <se:Abstract>
+            FeatureTypeStyle for labels at colour areas
+          </se:Abstract>
+        </se:Description>
+        <se:Rule>
+          <se:MaxScaleDenominator>3e2</se:MaxScaleDenominator>
+          <se:TextSymbolizer>
+          <Halo></Halo>
+            <se:VendorOption name="spaceAround">50</se:VendorOption>
+            <se:Label>
+              <ogc:Function name="Recode">
+                <ogc:Function name="numberFormat">
+                  <ogc:Literal>0.000000</ogc:Literal>
+                  <ogc:PropertyName>height</ogc:PropertyName>
+                </ogc:Function>
+                {{ range . -}}
+                {{ if .HasHigh -}}
+                    <ogc:Literal>
+                    {{- printf "%f" .High -}}
+                    </ogc:Literal><ogc:Literal>
+                    {{- printf "%g" .High -}}
+                    </ogc:Literal>
+                {{ end -}}
+                {{ end }}
+              </ogc:Function>
+            </se:Label>
+            <se:Font>
+              <se:SvgParameter name="font-size">80</se:SvgParameter>
+              <se:SvgParameter name="font-weight">bold</se:SvgParameter>
+              <se:SvgParameter name="font-family">Sans Serif</se:SvgParameter>
+            </se:Font>
+            <se:LabelPlacement>
+              <se:LinePlacement>
+                <se:PerpendicularOffset>5</se:PerpendicularOffset>
+              </se:LinePlacement>
+            </se:LabelPlacement>
+            <se:Fill>
+              <se:SvgParameter name="fill">#000000</se:SvgParameter>
+            </se:Fill>
+          </se:TextSymbolizer>
+        </se:Rule>
+        <se:Rule>
+          <se:MaxScaleDenominator>5e2</se:MaxScaleDenominator>
+          <se:MinScaleDenominator>3e2</se:MinScaleDenominator>
+          <se:TextSymbolizer>
+          <Halo></Halo>
+            <se:VendorOption name="spaceAround">80</se:VendorOption>
+            <se:Label>
+              <ogc:Function name="Recode">
+                <ogc:Function name="numberFormat">
+                  <ogc:Literal>0.000000</ogc:Literal>
+                  <ogc:PropertyName>height</ogc:PropertyName>
+                </ogc:Function>
+                {{ range . -}}
+                {{ if .HasHigh -}}
+                    <ogc:Literal>
+                    {{- printf "%f" .High -}}
+                    </ogc:Literal><ogc:Literal>
+                    {{- printf "%g" .High -}}
+                    </ogc:Literal>
+                {{ end -}}
+                {{ end }}
+              </ogc:Function>
+            </se:Label>
+            <se:Font>
+              <se:SvgParameter name="font-size">40</se:SvgParameter>
+              <se:SvgParameter name="font-weight">bold</se:SvgParameter>
+              <se:SvgParameter name="font-family">Sans Serif</se:SvgParameter>
+            </se:Font>
+            <se:LabelPlacement>
+              <se:LinePlacement>
+                <se:PerpendicularOffset>5</se:PerpendicularOffset>
+              </se:LinePlacement>
+            </se:LabelPlacement>
+            <se:Fill>
+              <se:SvgParameter name="fill">#000000</se:SvgParameter>
+            </se:Fill>
+          </se:TextSymbolizer>
+        </se:Rule>
+        <se:Rule>
+          <se:MaxScaleDenominator>7e2</se:MaxScaleDenominator>
+          <se:MinScaleDenominator>5e2</se:MinScaleDenominator>
+          <se:TextSymbolizer>
+          <Halo></Halo>
+            <se:VendorOption name="spaceAround">50</se:VendorOption>
+            <se:Label>
+              <ogc:Function name="Recode">
+                <ogc:Function name="numberFormat">
+                  <ogc:Literal>0.000000</ogc:Literal>
+                  <ogc:PropertyName>height</ogc:PropertyName>
+                </ogc:Function>
+                {{ range . -}}
+                {{ if .HasHigh -}}
+                    <ogc:Literal>
+                    {{- printf "%f" .High -}}
+                    </ogc:Literal><ogc:Literal>
+                    {{- printf "%g" .High -}}
+                    </ogc:Literal>
+                {{ end -}}
+                {{ end }}
+              </ogc:Function>
+            </se:Label>
+            <se:Font>
+              <se:SvgParameter name="font-size">20</se:SvgParameter>
+              <se:SvgParameter name="font-weight">bold</se:SvgParameter>
+              <se:SvgParameter name="font-family">Sans Serif</se:SvgParameter>
+            </se:Font>
+            <se:LabelPlacement>
+              <se:LinePlacement>
+                <se:PerpendicularOffset>5</se:PerpendicularOffset>
+              </se:LinePlacement>
+            </se:LabelPlacement>
+            <se:Fill>
+              <se:SvgParameter name="fill">#000000</se:SvgParameter>
+            </se:Fill>
+          </se:TextSymbolizer>
+        </se:Rule>
+        <se:Rule>
+          <se:MaxScaleDenominator>6e3</se:MaxScaleDenominator>
+          <se:MinScaleDenominator>7e2</se:MinScaleDenominator>
+          <se:TextSymbolizer>
+            <Halo></Halo>
+            <se:VendorOption name="spaceAround">50</se:VendorOption>
+            <se:Label>
+              <ogc:Function name="Recode">
+                <ogc:Function name="numberFormat">
+                  <ogc:Literal>0.000000</ogc:Literal>
+                  <ogc:PropertyName>height</ogc:PropertyName>
+                </ogc:Function>
+                {{ range . -}}
+                {{ if .HasHigh -}}
+                    <ogc:Literal>
+                    {{- printf "%f" .High -}}
+                    </ogc:Literal><ogc:Literal>
+                    {{- printf "%g" .High -}}
+                    </ogc:Literal>
+                {{ end -}}
+                {{ end }}
+              </ogc:Function>
+            </se:Label>
+            <se:Font>
+              <se:SvgParameter name="font-size">12</se:SvgParameter>
+              <se:SvgParameter name="font-weight">bold</se:SvgParameter>
+              <se:SvgParameter name="font-family">Sans Serif</se:SvgParameter>
+            </se:Font>
+            <se:LabelPlacement>
+              <se:LinePlacement>
+                <se:PerpendicularOffset>5</se:PerpendicularOffset>
+              </se:LinePlacement>
+            </se:LabelPlacement>
+            <se:Fill>
+              <se:SvgParameter name="fill">#000000</se:SvgParameter>
+            </se:Fill>
+          </se:TextSymbolizer>
+        </se:Rule>
+      </se:FeatureTypeStyle>
+    </UserStyle>
+  </NamedLayer>
+</StyledLayerDescriptor>