diff --git a/client/src/components/geoJS/LayerManager.vue b/client/src/components/geoJS/LayerManager.vue index 88340973..4ab7b5c7 100644 --- a/client/src/components/geoJS/LayerManager.vue +++ b/client/src/components/geoJS/LayerManager.vue @@ -2,7 +2,12 @@ import { defineComponent, nextTick, onMounted, onUnmounted, PropType, Ref, ref, watch } from "vue"; import * as d3 from "d3"; import { SpectrogramAnnotation, SpectrogramSequenceAnnotation } from "../../api/api"; -import { geojsonToSpectro, SpectroInfo, textColorFromBackground } from "./geoJSUtils"; +import { + annotationSpreadAcrossPulsesWarning, + geojsonToSpectro, + SpectroInfo, + textColorFromBackground, +} from "./geoJSUtils"; import EditAnnotationLayer from "./layers/editAnnotationLayer"; import RectangleLayer from "./layers/rectangleLayer"; import CompressedOverlayLayer from "./layers/compressedOverlayLayer"; @@ -13,6 +18,7 @@ import FreqLayer from "./layers/freqLayer"; import SpeciesLayer from "./layers/speciesLayer"; import SpeciesSequenceLayer from "./layers/speciesSequenceLayer"; import MeasureToolLayer from "./layers/measureToolLayer"; +import BoundingBoxLayer from "./layers/boundingBoxLayer"; import { cloneDeep } from "lodash"; import useState from "@use/useState"; export default defineComponent({ @@ -66,6 +72,8 @@ export default defineComponent({ backgroundColor, measuring, frequencyRulerY, + drawingBoundingBox, + boundingBoxError, } = useState(); const selectedAnnotationId: Ref = ref(null); const hoveredAnnotationId: Ref = ref(null); @@ -83,6 +91,7 @@ export default defineComponent({ let speciesLayer: SpeciesLayer; let speciesSequenceLayer: SpeciesSequenceLayer; let measureToolLayer: MeasureToolLayer; + let boundingBoxLayer: BoundingBoxLayer; const displayError = ref(false); const errorMsg = ref(""); @@ -214,9 +223,9 @@ export default defineComponent({ if (index !== -1 && props.spectroInfo && selectedType.value === 'pulse') { // update bounds for the localAnnotation const conversionResult = geojsonToSpectro(geoJSON, props.spectroInfo, props.scaledWidth, props.scaledHeight); - if (conversionResult.error) { + if (conversionResult.warning) { displayError.value = true; - errorMsg.value = conversionResult.error; + errorMsg.value = conversionResult.warning; return; } const { low_freq, high_freq, start_time, end_time } = conversionResult; @@ -232,9 +241,9 @@ export default defineComponent({ if (index !== -1 && props.spectroInfo && selectedType.value === 'sequence') { // update bounds for the localAnnotation const conversionResult = geojsonToSpectro(geoJSON, props.spectroInfo, props.scaledWidth, props.scaledHeight); - if (conversionResult.error) { + if (conversionResult.warning && conversionResult.warning !== annotationSpreadAcrossPulsesWarning) { displayError.value = true; - errorMsg.value = conversionResult.error; + errorMsg.value = conversionResult.warning; return; } const { start_time, end_time } = conversionResult; @@ -253,9 +262,11 @@ export default defineComponent({ if (geoJSON && props.spectroInfo) { const conversionResult = geojsonToSpectro(geoJSON, props.spectroInfo, props.scaledWidth, props.scaledHeight); - if (conversionResult.error) { + if (conversionResult.warning + && !(creationType.value === 'sequence' && conversionResult.warning === annotationSpreadAcrossPulsesWarning) + ) { displayError.value = true; - errorMsg.value = conversionResult.error; + errorMsg.value = conversionResult.warning; return; } const { low_freq, high_freq, start_time, end_time } = conversionResult; @@ -292,6 +303,10 @@ export default defineComponent({ const { yValue } = data; frequencyRulerY.value = yValue || 0; } + if (type === "bbox:error") { + const { error } = data; + boundingBoxError.value = error || ''; + } }; const getDataForLayers = () => { @@ -537,6 +552,18 @@ export default defineComponent({ } }); + if (!boundingBoxLayer) { + boundingBoxLayer = new BoundingBoxLayer(props.geoViewerRef, event, props.spectroInfo, drawingBoundingBox.value); + boundingBoxLayer.setScaledDimensions(props.scaledWidth, props.scaledHeight); + } + watch(drawingBoundingBox, () => { + if (drawingBoundingBox.value) { + boundingBoxLayer.enableDrawing(); + } else { + boundingBoxLayer.disableDrawing(); + } + }); + timeLayer.setDisplaying({ pulse: configuration.value.display_pulse_annotations, sequence: configuration.value.display_sequence_annotations }); timeLayer.formatData(localAnnotations.value, sequenceAnnotations.value); freqLayer.formatData(localAnnotations.value); @@ -593,7 +620,7 @@ export default defineComponent({ compressedOverlayLayer.formatData(props.spectroInfo.start_times, props.spectroInfo.end_times, props.yScale); compressedOverlayLayer.redraw(); } else { - compressedOverlayLayer?.disable(); + compressedOverlayLayer?.disable(); } } editAnnotationLayer?.setScaledDimensions(props.scaledWidth, props.scaledHeight); @@ -714,6 +741,9 @@ export default defineComponent({ if (measureToolLayer) { measureToolLayer.setTextColor(textColor); } + if (boundingBoxLayer) { + boundingBoxLayer.setTextColor(textColor); + } } watch([backgroundColor, colorScheme], updateColorFilter); diff --git a/client/src/components/geoJS/geoJSUtils.ts b/client/src/components/geoJS/geoJSUtils.ts index 12d47961..34df93c6 100644 --- a/client/src/components/geoJS/geoJSUtils.ts +++ b/client/src/components/geoJS/geoJSUtils.ts @@ -1,6 +1,8 @@ import { ref, Ref } from "vue"; import geo from "geojs"; +const annotationSpreadAcrossPulsesWarning = 'Start or End Time spread across pulses. This is not allowed in compressed annotations'; + const useGeoJS = () => { // eslint-disable-next-line @typescript-eslint/no-explicit-any const geoViewer: Ref = ref(); @@ -506,7 +508,7 @@ function geojsonToSpectro( spectroInfo: SpectroInfo, scaledWidth = 0, scaledHeight = 0, -): { error?: string; start_time: number; end_time: number; low_freq: number; high_freq: number } { +): { warning?: string; start_time: number; end_time: number; low_freq: number; high_freq: number } { const adjustedWidth = scaledWidth > spectroInfo.width ? scaledWidth : spectroInfo.width; const adjustedHeight = scaledHeight > spectroInfo.height ? scaledHeight : spectroInfo.height; @@ -543,6 +545,9 @@ function geojsonToSpectro( let additivePixels = 0; let start_time = -1; let end_time = -1; + let warn = false; + let startIndex = 0; + let endIndex = 0; for (let i = 0; i < start_times.length; i += 1) { // convert the start/end time to a pixel const nextPixels = (widths && widths[i]) || 0; @@ -552,6 +557,7 @@ function geojsonToSpectro( const lowPixels = start - additivePixels; const lowTime = start_times[i] + lowPixels / timeToPixels; start_time = Math.round(lowTime); + startIndex = i; } if ( end_time === -1 && @@ -562,17 +568,20 @@ function geojsonToSpectro( const highPixels = end - additivePixels; const highTime = start_times[i] + highPixels / timeToPixels; end_time = Math.round(highTime); + endIndex = i; } additivePixels += nextPixels; } + if (startIndex !== endIndex) { + warn = true; + } const heightScale = adjustedHeight / (spectroInfo.high_freq - spectroInfo.low_freq); const high_freq = Math.round(spectroInfo.high_freq - coords[1][1] / heightScale); const low_freq = Math.round(spectroInfo.high_freq - coords[3][1] / heightScale); - if (start_time === -1 || end_time === -1) { + if (warn) { // the time spreads across multiple pulses and isn't allowed; return { - error: - "Start or End Time spread across pusles. This is not allowed in compressed annotations", + warning: annotationSpreadAcrossPulsesWarning, start_time, end_time, low_freq, @@ -648,6 +657,14 @@ function textColorFromBackground(rgbString: string): "black" | "white" { return getContrastingColor(r, g, b); } +/** + * correct matching of drag handle to cursor direction relies on strict ordering of + * vertices within the GeoJSON coordinate list using utils.reOrdergeoJSON() + * and utils.reOrderBounds() + */ +const rectVertex = ["sw-resize", "nw-resize", "ne-resize", "se-resize"]; +const rectEdge = ["w-resize", "n-resize", "e-resize", "s-resize"]; + export { spectroToGeoJSon, geojsonToSpectro, @@ -655,5 +672,8 @@ export { useGeoJS, spectroToCenter, spectroSequenceToGeoJSon, - textColorFromBackground + textColorFromBackground, + rectVertex, + rectEdge, + annotationSpreadAcrossPulsesWarning, }; diff --git a/client/src/components/geoJS/layers/boundingBoxLayer.ts b/client/src/components/geoJS/layers/boundingBoxLayer.ts new file mode 100644 index 00000000..86616116 --- /dev/null +++ b/client/src/components/geoJS/layers/boundingBoxLayer.ts @@ -0,0 +1,231 @@ +import geo, { GeoEvent } from 'geojs'; +import BaseTextLayer from "./baseTextLayer"; +import { geojsonToSpectro, SpectroInfo } from '../geoJSUtils'; +import { LayerStyle, RectGeoJSData, TextData } from './types'; + +type BoundingBoxTextData = TextData & { textAlign?: 'start' | 'end' | 'center', textBaseline?: 'top' | 'middle' | 'bottom' }; + +export default class BoundingBoxLayer extends BaseTextLayer { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + boxLayer: any; + drawing: boolean; + boxError: string | undefined; + + constructor( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + geoViewerRef: any, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + event: (name: string, data: any) => void, + spectroInfo: SpectroInfo, + drawing?: boolean, + ) { + super(geoViewerRef, event, spectroInfo); + + const textLayer = this.geoViewerRef.createLayer('feature', { + features: ['text'] + }); + this.textLayer = textLayer + .createFeature('text') + .text((data: BoundingBoxTextData) => data.text) + .style(this.createTextStyle()) + .position((data: BoundingBoxTextData) => ({ + x: data.x, + y: data.y, + })); + + this.drawing = drawing || false; + this.boxError = undefined; + + this.initialize(); + } + + initialize() { + if (!this.boxLayer) { + this.boxLayer = this.geoViewerRef.createLayer('annotation', { + clickToEdit: true, + showLabels: false, + continuousPoiintProximity: false, + finalPointProximity: 1, + adjacentPointProximity: 1, + }); + } + this.boxLayer.geoOn(geo.event.annotation.state, (e: GeoEvent) => this.handleAnnotationState(e)); + this.boxLayer.geoOn(geo.event.annotation.edit_action, (e: GeoEvent) => this.handleAnnotationEditAction(e)); + } + + handleAnnotationState(e: GeoEvent) { + if (this.boxLayer !== e.annotation.layer()) { + // not an annotation owned by this object + return; + } + if (e.annotation.state() === 'done') { + this.event("update:cursor", { cursor: "default" }); + this.updateLabels(e.annotation); + this.applyStyles(); + this.redraw(); + } + } + + handleAnnotationEditAction(e: GeoEvent) { + if (this.boxLayer !== e.annotation.layer()) { + return; + } + if (e.annotation.state() === 'edit') { + this.updateLabels(e.annotation); + } + } + + updateErrorState(error: string | undefined) { + this.boxError = error; + const message = error ? 'The current bounding box spans multiple pulses. The measurement labels are correct, but it is not to scale.' : null; + this.event("bbox:error", { error: message }); + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + updateLabels(annotation: any) { + const geojsonData = annotation.geojson(); + const coordinates = geojsonData.geometry.coordinates[0]; + const { + start_time: startTime, + end_time: endTime, + low_freq: lowFreq, + high_freq: highFreq, + warning, + } = geojsonToSpectro( + geojsonData, + this.spectroInfo, + this.scaledWidth, + this.scaledHeight, + ); + + this.updateErrorState(warning); + + this.textData = [ + { + text: `${startTime}ₘₛ`, + x: coordinates[0][0], + y: coordinates[0][1] + 12, + textAlign: 'center', + textBaseline: 'top', + offsetX: 0, + offsetY: 0, + }, + { + text: `${endTime}ₘₛ`, + x: coordinates[3][0], + y: coordinates[3][1] + 12, + textAlign: 'center', + textBaseline: 'top', + }, + { + text: `${(lowFreq / 1000).toFixed(1)}KHz`, + x: coordinates[3][0] + 5, + y: coordinates[3][1], + textAlign: 'start', + textBaseline: 'middle', + }, + { + text: `${(highFreq / 1000).toFixed(1)}KHz`, + x: coordinates[2][0] + 5, + y: coordinates[2][1], + textAlign: 'start', + textBaseline: 'middle', + }, + { + text: `${endTime - startTime}ₘₛ`, + x: (coordinates[0][0] + coordinates[2][0]) / 2, + y: (coordinates[0][1] + coordinates[1][1]) / 2, + textAlign: 'center', + textBaseline: 'middle', + }, + ]; + this.redraw(); + } + + enableDrawing() { + this.drawing = true; + this.event("update:cursor", { cursor: "mdi-vector-rectangle" }); + if (this.boxLayer) { + this.boxLayer.mode('rectangle'); + } + } + + disableDrawing() { + this.drawing = false; + if (this.boxLayer) { + this.boxLayer.mode(null); + this.clearAnnotations(); + } + if (this.textLayer) { + this.textData = []; + this.textLayer.data(this.textData).draw(); + } + this.updateErrorState(undefined); + } + + clearAnnotations() { + if (this.boxLayer) { + this.boxLayer.removeAllAnnotations(); + } + } + + redraw() { + if (this.textLayer) { + this.textLayer.data(this.textData).style(this.createTextStyle()).draw(); + } + if (this.boxLayer) { + this.applyStyles(); + this.boxLayer.draw(); + } + } + + applyStyles() { + if (this.boxLayer && this.boxLayer.annotations().length) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + this.boxLayer.annotations().forEach((annotation: any) => { + annotation.style(this.createRectStyle()); + annotation.editHandleStyle(this.createEditHandleStyle()); + }); + } + } + + _isDarkMode() { + return this.color === 'white'; + } + + createRectStyle(): LayerStyle { + return { + strokeWidth: 1.0, + antialiasing: 0, + stroke: true, + uniformPolygon: true, + fill: false, + strokeColor: this._isDarkMode() ? 'yellow' : 'blue', + }; + } + + createTextStyle(): LayerStyle { + return { + fontSize: '16px', + textAlign: (data) => data.textAlign || 'start', + textBaseline: (data) => data.textBaseline || 'bottom', + color: () => this.color, + offset: (data) => ({ + x: data.offsetX || 0, + y: data.offsetY || 0, + }), + textScaled: this.textScaled, + + }; + } + + createEditHandleStyle() { + return { + handles: { + rotate: false, + resize: false, + }, + strokeColor: this._isDarkMode() ? 'yellow' : 'blue', + }; + } +} diff --git a/client/src/components/geoJS/layers/editAnnotationLayer.ts b/client/src/components/geoJS/layers/editAnnotationLayer.ts index 6be5c956..65e88f60 100644 --- a/client/src/components/geoJS/layers/editAnnotationLayer.ts +++ b/client/src/components/geoJS/layers/editAnnotationLayer.ts @@ -5,19 +5,12 @@ import { spectroToGeoJSon, reOrdergeoJSON, spectroSequenceToGeoJSon, + rectVertex, + rectEdge, } from "../geoJSUtils"; import { SpectrogramAnnotation, SpectrogramSequenceAnnotation } from "../../../api/api"; -import { LayerStyle } from "./types"; -import { GeoJSON } from "geojson"; - -export type EditAnnotationTypes = "rectangle"; - -interface RectGeoJSData { - id: number; - selected: boolean; - editing: boolean | string; - polygon: GeoJSON.Polygon; -} +import { LayerStyle, RectGeoJSData, EditAnnotationTypes } from "./types"; +import { GeoJSON } from 'geojson'; const typeMapper = new Map([ ["LineString", "line"], @@ -26,13 +19,6 @@ const typeMapper = new Map([ ["rectangle", "rectangle"], ]); -/** - * correct matching of drag handle to cursor direction relies on strict ordering of - * vertices within the GeoJSON coordinate list using utils.reOrdergeoJSON() - * and utils.reOrderBounds() - */ -const rectVertex = ["sw-resize", "nw-resize", "ne-resize", "se-resize"]; -const rectEdge = ["w-resize", "n-resize", "e-resize", "s-resize"]; /** * This class is used to edit annotations within the viewer * It will do and display different things based on it either being in @@ -416,7 +402,7 @@ export default class EditAnnotationLayer { } if (typeof this.type !== "string") { throw new Error( - `editing props needs to be a string of value + `editing props needs to be a string of value ${geo.listAnnotations().join(", ")} when geojson prop is not set` ); diff --git a/client/src/components/geoJS/layers/types.ts b/client/src/components/geoJS/layers/types.ts index 4c2aa525..8614ff83 100644 --- a/client/src/components/geoJS/layers/types.ts +++ b/client/src/components/geoJS/layers/types.ts @@ -9,7 +9,7 @@ export interface LayerStyle { strokeColor?: StyleFunction | PointFunction; fillColor?: StyleFunction | PointFunction; fillOpacity?: StyleFunction | PointFunction; - visible?: StyleFunction | PointFunction; + visible?: boolean | ((data: D) => boolean); position?: (point: [number, number]) => { x: number; y: number }; color?: (data: D) => string; textOpacity?: (data: D) => number; @@ -21,9 +21,10 @@ export interface LayerStyle { textBaseline?: ((data: D) => string) | string; textScaled?: ((data: D) => number | undefined) | number | undefined; [x: string]: unknown; - visible?: (data: D) => boolean; } +export type EditAnnotationTypes = "rectangle"; + export interface RectGeoJSData { id: number; selected: boolean; diff --git a/client/src/use/useState.ts b/client/src/use/useState.ts index f4b2452b..277efc2c 100644 --- a/client/src/use/useState.ts +++ b/client/src/use/useState.ts @@ -66,6 +66,11 @@ const frequencyRulerY: Ref = ref(0); const toggleMeasureMode = () => { measuring.value = !measuring.value; }; +const drawingBoundingBox = ref(false); +const boundingBoxError = ref(''); +const toggleDrawingBoundingBox = () => { + drawingBoundingBox.value = !drawingBoundingBox.value; +}; type AnnotationState = "" | "editing" | "creating" | "disabled"; export default function useState() { @@ -148,6 +153,9 @@ export default function useState() { measuring, toggleMeasureMode, frequencyRulerY, + drawingBoundingBox, + boundingBoxError, + toggleDrawingBoundingBox, colorSchemes, colorScheme, backgroundColor, diff --git a/client/src/views/NABat/NABatSpectrogram.vue b/client/src/views/NABat/NABatSpectrogram.vue index 6ee390ec..37fc0e19 100644 --- a/client/src/views/NABat/NABatSpectrogram.vue +++ b/client/src/views/NABat/NABatSpectrogram.vue @@ -62,6 +62,8 @@ export default defineComponent({ configuration, measuring, toggleMeasureMode, + drawingBoundingBox, + toggleDrawingBoundingBox, } = useState(); const secondsWarning = 60; const { prompt } = usePrompt(); @@ -166,7 +168,12 @@ export default defineComponent({ timeRef.value = time; freqRef.value = freq; }; - watch(compressed, () => loadData()); + watch(compressed, () => { + loadData(); + if (drawingBoundingBox.value) { + toggleDrawingBoundingBox(); + } + }); const toggleCompressedOverlay = () => { @@ -209,6 +216,8 @@ export default defineComponent({ sideTab, measuring, toggleMeasureMode, + drawingBoundingBox, + toggleDrawingBoundingBox, // Color Scheme colorSchemes, colorScheme, @@ -281,6 +290,20 @@ export default defineComponent({ + + + Draw bounding boxes to measure pulses +