diff --git a/.webpack/webpack.common.js b/.webpack/webpack.common.js
index 797340753c..a1e53a4dc4 100644
--- a/.webpack/webpack.common.js
+++ b/.webpack/webpack.common.js
@@ -67,7 +67,6 @@ const config = {
MCT: path.join(projectRootDir, 'src/MCT'),
testUtils: path.join(projectRootDir, 'src/utils/testUtils.js'),
objectUtils: path.join(projectRootDir, 'src/api/objects/object-utils.js'),
- kdbush: path.join(projectRootDir, 'node_modules/kdbush/kdbush.min.js'),
utils: path.join(projectRootDir, 'src/utils')
}
},
diff --git a/e2e/tests/functional/plugins/imagery/exampleImagery.e2e.spec.js b/e2e/tests/functional/plugins/imagery/exampleImagery.e2e.spec.js
index 5349592eef..16792644d6 100644
--- a/e2e/tests/functional/plugins/imagery/exampleImagery.e2e.spec.js
+++ b/e2e/tests/functional/plugins/imagery/exampleImagery.e2e.spec.js
@@ -30,6 +30,7 @@ const { test, expect } = require('../../../../pluginFixtures');
const { createDomainObjectWithDefaults } = require('../../../../appActions');
const backgroundImageSelector = '.c-imagery__main-image__background-image';
const panHotkey = process.platform === 'linux' ? ['Shift', 'Alt'] : ['Alt'];
+const tagHotkey = ['Shift', 'Alt'];
const expectedAltText = process.platform === 'linux' ? 'Shift+Alt drag to pan' : 'Alt drag to pan';
const thumbnailUrlParamsRegexp = /\?w=100&h=100/;
@@ -44,7 +45,7 @@ test.describe('Example Imagery Object', () => {
// Verify that the created object is focused
await expect(page.locator('.l-browse-bar__object-name')).toContainText(exampleImagery.name);
- await page.locator(backgroundImageSelector).hover({ trial: true });
+ await page.locator('.c-imagery__main-image__bg').hover({ trial: true });
});
test('Can use Mouse Wheel to zoom in and out of latest image', async ({ page }) => {
@@ -72,11 +73,11 @@ test.describe('Example Imagery Object', () => {
test('Can use alt+drag to move around image once zoomed in', async ({ page }) => {
const deltaYStep = 100; //equivalent to 1x zoom
- await page.locator(backgroundImageSelector).hover({ trial: true });
+ await page.locator('.c-imagery__main-image__bg').hover({ trial: true });
// zoom in
await page.mouse.wheel(0, deltaYStep * 2);
- await page.locator(backgroundImageSelector).hover({ trial: true });
+ await page.locator('.c-imagery__main-image__bg').hover({ trial: true });
const zoomedBoundingBox = await page.locator(backgroundImageSelector).boundingBox();
const imageCenterX = zoomedBoundingBox.x + zoomedBoundingBox.width / 2;
const imageCenterY = zoomedBoundingBox.y + zoomedBoundingBox.height / 2;
@@ -131,6 +132,36 @@ test.describe('Example Imagery Object', () => {
expect(afterDownPanBoundingBox.y).toBeLessThan(afterUpPanBoundingBox.y);
});
+ test('Can use alt+shift+drag to create a tag', async ({ page }) => {
+ const canvas = page.locator('canvas');
+ await canvas.hover({ trial: true });
+
+ const canvasBoundingBox = await canvas.boundingBox();
+ const canvasCenterX = canvasBoundingBox.x + canvasBoundingBox.width / 2;
+ const canvasCenterY = canvasBoundingBox.y + canvasBoundingBox.height / 2;
+
+ await Promise.all(tagHotkey.map((x) => page.keyboard.down(x)));
+ await page.mouse.down();
+ // steps not working for me here
+ await page.mouse.move(canvasCenterX - 20, canvasCenterY - 20);
+ await page.mouse.move(canvasCenterX - 100, canvasCenterY - 100);
+ await page.mouse.up();
+ await Promise.all(tagHotkey.map((x) => page.keyboard.up(x)));
+
+ //Wait for canvas to stablize.
+ await canvas.hover({ trial: true });
+
+ // add some tags
+ await page.getByText('Annotations').click();
+ await page.getByRole('button', { name: /Add Tag/ }).click();
+ await page.getByPlaceholder('Type to select tag').click();
+ await page.getByText('Driving').click();
+
+ await page.getByRole('button', { name: /Add Tag/ }).click();
+ await page.getByPlaceholder('Type to select tag').click();
+ await page.getByText('Science').click();
+ });
+
test('Can use + - buttons to zoom on the image @unstable', async ({ page }) => {
await buttonZoomOnImageAndAssert(page);
});
@@ -713,7 +744,6 @@ async function panZoomAndAssertImageProperties(page) {
async function mouseZoomOnImageAndAssert(page, factor = 2) {
// Zoom in
const originalImageDimensions = await page.locator(backgroundImageSelector).boundingBox();
- await page.locator(backgroundImageSelector).hover({ trial: true });
const deltaYStep = 100; // equivalent to 1x zoom
await page.mouse.wheel(0, deltaYStep * factor);
const zoomedBoundingBox = await page.locator(backgroundImageSelector).boundingBox();
@@ -724,7 +754,7 @@ async function mouseZoomOnImageAndAssert(page, factor = 2) {
await page.mouse.move(imageCenterX, imageCenterY);
// Wait for zoom animation to finish
- await page.locator(backgroundImageSelector).hover({ trial: true });
+ await page.locator('.c-imagery__main-image__bg').hover({ trial: true });
const imageMouseZoomed = await page.locator(backgroundImageSelector).boundingBox();
if (factor > 0) {
diff --git a/package.json b/package.json
index 986b130cf7..2afcd7229d 100644
--- a/package.json
+++ b/package.json
@@ -30,6 +30,7 @@
"eslint-plugin-you-dont-need-lodash-underscore": "6.12.0",
"eventemitter3": "1.2.0",
"file-saver": "2.0.5",
+ "flatbush": "4.1.0",
"git-rev-sync": "3.0.2",
"html2canvas": "1.4.1",
"imports-loader": "4.0.1",
@@ -44,7 +45,6 @@
"karma-sourcemap-loader": "0.4.0",
"karma-spec-reporter": "0.0.36",
"karma-webpack": "5.0.0",
- "kdbush": "3.0.0",
"location-bar": "3.0.1",
"lodash": "4.17.21",
"mini-css-extract-plugin": "2.7.6",
diff --git a/src/plugins/imagery/components/AnnotationsCanvas.vue b/src/plugins/imagery/components/AnnotationsCanvas.vue
new file mode 100644
index 0000000000..a4e1a1632b
--- /dev/null
+++ b/src/plugins/imagery/components/AnnotationsCanvas.vue
@@ -0,0 +1,418 @@
+
+
+
+
+
+
+
diff --git a/src/plugins/imagery/components/Compass/compass.scss b/src/plugins/imagery/components/Compass/compass.scss
index 357e88754c..368c1062ab 100644
--- a/src/plugins/imagery/components/Compass/compass.scss
+++ b/src/plugins/imagery/components/Compass/compass.scss
@@ -19,7 +19,7 @@ $elemBg: rgba(black, 0.7);
position: absolute;
left: 0;
top: 0;
- z-index: 2;
+ z-index: 3;
@include userSelectNone;
}
diff --git a/src/plugins/imagery/components/ImageThumbnail.vue b/src/plugins/imagery/components/ImageThumbnail.vue
index 598abd6183..d15ec361a7 100644
--- a/src/plugins/imagery/components/ImageThumbnail.vue
+++ b/src/plugins/imagery/components/ImageThumbnail.vue
@@ -38,6 +38,11 @@
fetchpriority="low"
@load="imageLoadCompleted"
/>
+
+
{{ image.formattedTime }}
@@ -66,6 +71,12 @@ export default {
type: Boolean,
required: true
},
+ imageryAnnotations: {
+ type: Array,
+ default() {
+ return [];
+ }
+ },
viewableArea: {
type: Object,
default: function () {
@@ -125,6 +136,11 @@ export default {
width: `${width}px`,
height: `${height}px`
};
+ },
+ showAnnotationIndicator() {
+ return this.imageryAnnotations.some((annotation) => {
+ return !annotation._deleted;
+ });
}
},
methods: {
diff --git a/src/plugins/imagery/components/ImageryView.vue b/src/plugins/imagery/components/ImageryView.vue
index 0ac1840fec..f1564ec0e1 100644
--- a/src/plugins/imagery/components/ImageryView.vue
+++ b/src/plugins/imagery/components/ImageryView.vue
@@ -88,6 +88,13 @@
:image="focusedImage"
:sized-image-dimensions="sizedImageDimensions"
/>
+
@@ -173,6 +180,7 @@
:key="`${image.thumbnailUrl || image.url}-${image.time}-${index}`"
:image="image"
:active="focusedImageIndex === index"
+ :imagery-annotations="imageryAnnotations[image.time]"
:selected="focusedImageIndex === index && isPaused"
:real-time="!isFixed"
:viewable-area="focusedImageIndex === index ? viewableArea : null"
@@ -200,6 +208,7 @@ import Compass from './Compass/Compass.vue';
import ImageControls from './ImageControls.vue';
import ImageThumbnail from './ImageThumbnail.vue';
import imageryData from '../../imagery/mixins/imageryData';
+import AnnotationsCanvas from './AnnotationsCanvas.vue';
const REFRESH_CSS_MS = 500;
const DURATION_TRACK_MS = 1000;
@@ -232,7 +241,8 @@ export default {
components: {
Compass,
ImageControls,
- ImageThumbnail
+ ImageThumbnail,
+ AnnotationsCanvas
},
mixins: [imageryData],
inject: ['openmct', 'domainObject', 'objectPath', 'currentView', 'imageFreshnessOptions'],
@@ -295,7 +305,8 @@ export default {
animateZoom: true,
imagePanned: false,
forceShowThumbnails: false,
- animateThumbScroll: false
+ animateThumbScroll: false,
+ imageryAnnotations: {}
};
},
computed: {
@@ -425,6 +436,19 @@ export default {
return result;
},
+ shouldDisplayAnnotations() {
+ const imageHeightAndWidth = this.sizedImageHeight !== 0 && this.sizedImageWidth !== 0;
+ const display =
+ this.focusedImage !== undefined &&
+ this.focusedImageNaturalAspectRatio !== undefined &&
+ this.imageContainerWidth !== undefined &&
+ this.imageContainerHeight !== undefined &&
+ imageHeightAndWidth &&
+ this.zoomFactor === 1 &&
+ this.imagePanned !== true;
+
+ return display;
+ },
shouldDisplayCompass() {
const imageHeightAndWidth = this.sizedImageHeight !== 0 && this.sizedImageWidth !== 0;
const display =
@@ -631,6 +655,9 @@ export default {
}
}
},
+ created() {
+ this.abortController = new AbortController();
+ },
async mounted() {
eventHelpers.extend(this);
this.focusedImageWrapper = this.$refs.focusedImageWrapper;
@@ -689,8 +716,12 @@ export default {
this.listenTo(this.focusedImageWrapper, 'wheel', this.wheelZoom, this);
this.loadVisibleLayers();
+ this.loadAnnotations();
+
+ this.openmct.selection.on('change', this.updateSelection);
},
beforeDestroy() {
+ this.abortController.abort();
this.persistVisibleLayers();
this.stopFollowingTimeContext();
@@ -716,6 +747,15 @@ export default {
}
this.stopListening(this.focusedImageWrapper, 'wheel', this.wheelZoom, this);
+
+ Object.keys(this.imageryAnnotations).forEach((time) => {
+ const imageAnnotationsForTime = this.imageryAnnotations[time];
+ imageAnnotationsForTime.forEach((imageAnnotation) => {
+ this.openmct.objects.destroyMutable(imageAnnotation);
+ });
+ });
+
+ this.openmct.selection.off('change', this.updateSelection);
},
methods: {
calculateViewHeight() {
@@ -743,6 +783,15 @@ export default {
this.timeContext.off('clock', this.trackDuration);
}
},
+ updateSelection(selection) {
+ const selectionType = selection?.[0]?.[0]?.context?.type;
+ const validSelectionTypes = ['annotation-search-result'];
+
+ if (!validSelectionTypes.includes(selectionType)) {
+ // wrong type of selection
+ return;
+ }
+ },
expand() {
// check for modifier keys so it doesnt interfere with the layout
if (this.cursorStates.modifierKeyPressed) {
@@ -832,6 +881,41 @@ export default {
});
}
},
+ async loadAnnotations(existingAnnotations) {
+ if (!this.openmct.annotation.getAvailableTags().length) {
+ // don't bother loading annotations if there are no tags
+ return;
+ }
+ let foundAnnotations = existingAnnotations;
+ if (!foundAnnotations) {
+ // attempt to load
+ foundAnnotations = await this.openmct.annotation.getAnnotations(
+ this.domainObject.identifier,
+ this.abortController.signal
+ );
+ }
+ foundAnnotations.forEach((foundAnnotation) => {
+ const targetId = Object.keys(foundAnnotation.targets)[0];
+ const timeForAnnotation = foundAnnotation.targets[targetId].time;
+ if (!this.imageryAnnotations[timeForAnnotation]) {
+ this.$set(this.imageryAnnotations, timeForAnnotation, []);
+ }
+
+ const annotationExtant = this.imageryAnnotations[timeForAnnotation].some(
+ (existingAnnotation) => {
+ return this.openmct.objects.areIdsEqual(
+ existingAnnotation.identifier,
+ foundAnnotation.identifier
+ );
+ }
+ );
+ if (!annotationExtant) {
+ const annotationArray = this.imageryAnnotations[timeForAnnotation];
+ const mutableAnnotation = this.openmct.objects.toMutable(foundAnnotation);
+ annotationArray.push(mutableAnnotation);
+ }
+ });
+ },
persistVisibleLayers() {
if (
this.domainObject.configuration &&
@@ -979,7 +1063,9 @@ export default {
}
await Vue.nextTick();
- this.$refs.thumbsWrapper.scrollLeft = scrollWidth;
+ if (this.$refs.thumbsWrapper) {
+ this.$refs.thumbsWrapper.scrollLeft = scrollWidth;
+ }
},
scrollHandler() {
if (this.isPaused) {
diff --git a/src/plugins/imagery/components/imagery-view.scss b/src/plugins/imagery/components/imagery-view.scss
index f14a6cebb0..09e8cb7d81 100644
--- a/src/plugins/imagery/components/imagery-view.scss
+++ b/src/plugins/imagery/components/imagery-view.scss
@@ -293,6 +293,13 @@
width: 100%;
}
+ &__annotation-indicator {
+ color: $colorClickIconButton;
+ position: absolute;
+ top: 6px;
+ right: 8px;
+ }
+
&__timestamp {
flex: 0 0 auto;
padding: 2px 3px;
@@ -540,3 +547,11 @@
align-self: flex-end;
}
}
+
+.c-image-canvas {
+ pointer-events: auto; // This allows the image element to receive a browser-level context click
+ position: absolute;
+ left: 0;
+ top: 0;
+ z-index: 2;
+}
diff --git a/src/plugins/plot/MctPlot.vue b/src/plugins/plot/MctPlot.vue
index 7d00b934fc..899af22c72 100644
--- a/src/plugins/plot/MctPlot.vue
+++ b/src/plugins/plot/MctPlot.vue
@@ -183,7 +183,7 @@ import MctTicks from './MctTicks.vue';
import MctChart from './chart/MctChart.vue';
import XAxis from './axis/XAxis.vue';
import YAxis from './axis/YAxis.vue';
-import KDBush from 'kdbush';
+import Flatbush from 'flatbush';
import _ from 'lodash';
const OFFSET_THRESHOLD = 10;
@@ -414,8 +414,8 @@ export default {
// on clicking on a search result we highlight the annotation and zoom - we know it's an annotation result when isAnnotationSearchResult === true
// We shouldn't zoom when we're selecting existing annotations to view them or creating new annotations.
const selectionType = selection?.[0]?.[0]?.context?.type;
- const validSelectionTypes = ['clicked-on-plot-selection', 'plot-annotation-search-result'];
- const isAnnotationSearchResult = selectionType === 'plot-annotation-search-result';
+ const validSelectionTypes = ['clicked-on-plot-selection', 'annotation-search-result'];
+ const isAnnotationSearchResult = selectionType === 'annotation-search-result';
if (!validSelectionTypes.includes(selectionType)) {
// wrong type of selection
@@ -1398,6 +1398,24 @@ export default {
return annotationsByPoints.flat();
},
+ searchWithFlatbush(seriesData, seriesModel, boundingBox) {
+ const flatbush = new Flatbush(seriesData.length);
+ seriesData.forEach((point) => {
+ const x = seriesModel.getXVal(point);
+ const y = seriesModel.getYVal(point);
+ flatbush.add(x, y, x, y);
+ });
+ flatbush.finish();
+
+ const rangeResults = flatbush.search(
+ boundingBox.minX,
+ boundingBox.minY,
+ boundingBox.maxX,
+ boundingBox.maxY
+ );
+
+ return rangeResults;
+ },
getPointsInBox(boundingBoxPerYAxis, rawAnnotation) {
// load series models in KD-Trees
const seriesKDTrees = [];
@@ -1413,22 +1431,8 @@ export default {
const seriesData = seriesModel.getSeriesData();
if (seriesData && seriesData.length) {
- const kdTree = new KDBush(
- seriesData,
- (point) => {
- return seriesModel.getXVal(point);
- },
- (point) => {
- return seriesModel.getYVal(point);
- }
- );
const searchResults = [];
- const rangeResults = kdTree.range(
- boundingBox.minX,
- boundingBox.minY,
- boundingBox.maxX,
- boundingBox.maxY
- );
+ const rangeResults = this.searchWithFlatbush(seriesData, seriesModel, boundingBox);
rangeResults.forEach((id) => {
const seriesDatum = seriesData[id];
if (seriesDatum) {
diff --git a/src/ui/layout/search/AnnotationSearchResult.vue b/src/ui/layout/search/AnnotationSearchResult.vue
index 1f0fe22449..cff1f05662 100644
--- a/src/ui/layout/search/AnnotationSearchResult.vue
+++ b/src/ui/layout/search/AnnotationSearchResult.vue
@@ -122,11 +122,11 @@ export default {
mounted() {
this.previewAction = new PreviewAction(this.openmct);
this.previewAction.on('isVisible', this.togglePreviewState);
- this.clickedPlotAnnotation = this.clickedPlotAnnotation.bind(this);
+ this.fireAnnotationSelection = this.fireAnnotationSelection.bind(this);
},
destroyed() {
this.previewAction.off('isVisible', this.togglePreviewState);
- this.openmct.selection.off('change', this.clickedPlotAnnotation);
+ this.openmct.selection.off('change', this.fireAnnotationSelection);
},
methods: {
clickedResult(event) {
@@ -139,18 +139,15 @@ export default {
if (!this.openmct.router.isNavigatedObject(objectPath)) {
// if we're not on the correct page, navigate to the object,
// then wait for the selection event to fire before issuing a new selection
- if (
- this.result.annotationType === this.openmct.annotation.ANNOTATION_TYPES.PLOT_SPATIAL ||
- this.result.annotationType === this.openmct.annotation.ANNOTATION_TYPES.GEOSPATIAL
- ) {
- this.openmct.selection.on('change', this.clickedPlotAnnotation);
+ if (this.result.annotationType) {
+ this.openmct.selection.on('change', this.fireAnnotationSelection);
}
this.openmct.router.navigate(resultUrl);
} else {
// if this is the navigated object, then we are already on the correct page
// and just need to issue the selection event
- this.clickedPlotAnnotation();
+ this.fireAnnotationSelection();
}
}
},
@@ -159,8 +156,8 @@ export default {
this.previewAction.invoke(objectPath);
}
},
- clickedPlotAnnotation() {
- this.openmct.selection.off('change', this.clickedPlotAnnotation);
+ fireAnnotationSelection() {
+ this.openmct.selection.off('change', this.fireAnnotationSelection);
const targetDetails = {};
const targetDomainObjects = {};
@@ -176,11 +173,11 @@ export default {
element: this.$el,
context: {
item: this.result.targetModels[0],
- type: 'plot-annotation-search-result',
+ type: 'annotation-search-result',
targetDetails,
targetDomainObjects,
annotations: [this.result],
- annotationType: this.openmct.annotation.ANNOTATION_TYPES.PLOT_SPATIAL,
+ annotationType: this.result.annotationType,
onAnnotationChange: () => {}
}
}