Merge branch 'master' into memory-leak-detection

This commit is contained in:
Andrew Henry 2023-07-07 09:46:01 -07:00 committed by GitHub
commit 4fff6b035b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
30 changed files with 1341 additions and 221 deletions

View File

@ -7,15 +7,33 @@ on:
- opened
jobs:
e2e-couchdb:
if: ${{ github.event.label.name == 'pr:e2e:couchdb' }} || ${{ github.event.action == 'opened' }}
if: github.event.label.name == 'pr:e2e:couchdb' || github.event.action == 'opened' && github.actor == 'dependabot[bot]'
runs-on: ubuntu-latest
timeout-minutes: 60
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
node-version: 'lts/gallium'
node-version: 'lts/hydrogen'
- name: Cache NPM dependencies
uses: actions/cache@v3
with:
path: ~/.npm
key: ${{ runner.os }}-node-${{ hashFiles('**/package.json') }}
restore-keys: |
${{ runner.os }}-node-
- run: npm install --cache ~/.npm --prefer-offline --no-audit --progress=false
- name: Login to DockerHub
uses: docker/login-action@v2
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- run: npx playwright@1.32.3 install
- run: npm install
- name: Start CouchDB Docker Container and Init with Setup Scripts
run: |
export $(cat src/plugins/persistence/couch/.env.ci | xargs)
@ -23,26 +41,31 @@ jobs:
sleep 3
bash src/plugins/persistence/couch/setup-couchdb.sh
bash src/plugins/persistence/couch/replace-localstorage-with-couchdb-indexhtml.sh
- name: Run CouchDB Tests and publish to deploysentinel
env:
DEPLOYSENTINEL_API_KEY: ${{ secrets.DEPLOYSENTINEL_API_KEY }}
run: npm run test:e2e:couchdb
- name: Publish Results to Codecov.io
env:
SUPER_SECRET: ${{ secrets.CODECOV_TOKEN }}
run: npm run cov:e2e:full:publish
- name: Archive test results
if: success() || failure()
uses: actions/upload-artifact@v3
with:
path: test-results
- name: Archive html test results
if: success() || failure()
uses: actions/upload-artifact@v3
with:
path: html-test-results
- name: Remove pr:e2e:couchdb label (if present)
if: ${{ contains(github.event.pull_request.labels.*.name, 'pr:e2e:couchdb') }}
if: always()
uses: actions/github-script@v6
with:
script: |
@ -56,5 +79,5 @@ jobs:
name: labelToRemove
});
} catch (error) {
core.warning(`Failed to remove 'pr:e2e:couchdb' label: ${error.message}`);
core.warning(`Failed to remove ' + labelToRemove + ' label: ${error.message}`);
}

View File

@ -7,31 +7,31 @@ on:
- opened
jobs:
e2e-full:
if: ${{ github.event.label.name == 'pr:e2e' }}
if: github.event.label.name == 'pr:e2e' || github.event.action == 'opened' && github.actor == 'dependabot[bot]'
runs-on: ${{ matrix.os }}
timeout-minutes: 60
strategy:
matrix:
os:
- ubuntu-latest
- windows-latest
steps:
- name: Trigger Success
uses: actions/github-script@v6
with:
script: |
github.rest.issues.createComment({
issue_number: context.issue.number,
owner: "nasa",
repo: "openmct",
body: 'Started e2e Run. Follow along: https://github.com/nasa/openmct/actions/runs/' + context.runId
})
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
node-version: '16'
node-version: 'lts/hydrogen'
- name: Cache NPM dependencies
uses: actions/cache@v3
with:
path: ~/.npm
key: ${{ runner.os }}-node-${{ hashFiles('**/package.json') }}
restore-keys: |
${{ runner.os }}-node-
- run: npx playwright@1.32.3 install
- run: npx playwright install chrome-beta
- run: npm install
- run: npm install --cache ~/.npm --prefer-offline --no-audit --progress=false
- run: npm run test:e2e:full -- --max-failures=40
- run: npm run cov:e2e:report || true
- shell: bash
@ -44,30 +44,9 @@ jobs:
uses: actions/upload-artifact@v3
with:
path: test-results
- name: Test success
if: ${{ success() }}
uses: actions/github-script@v6
with:
script: |
github.rest.issues.createComment({
issue_number: context.issue.number,
owner: "nasa",
repo: "openmct",
body: 'Success ✅ ! Build artifacts are here: https://github.com/nasa/openmct/actions/runs/' + context.runId
})
- name: Test failure
if: ${{ failure() }}
uses: actions/github-script@v6
with:
script: |
github.rest.issues.createComment({
issue_number: context.issue.number,
owner: "nasa",
repo: "openmct",
body: 'Failure ❌ ! Build artifacts are here: https://github.com/nasa/openmct/actions/runs/' + context.runId
})
- name: Remove pr:e2e label (if present)
if: ${{ contains(github.event.pull_request.labels.*.name, 'pr:e2e') }}
if: always()
uses: actions/github-script@v6
with:
script: |
@ -81,5 +60,5 @@ jobs:
name: labelToRemove
});
} catch (error) {
core.warning(`Failed to remove 'pr:e2e' label: ${error.message}`);
}
core.warning(`Failed to remove ' + labelToRemove + ' label: ${error.message}`);
}

View File

@ -14,7 +14,7 @@ jobs:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
node-version: 16
node-version: lts/hydrogen
- run: npm install
- run: |
echo "//registry.npmjs.org/:_authToken=$NODE_AUTH_TOKEN" >> ~/.npmrc
@ -29,7 +29,7 @@ jobs:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
node-version: 16
node-version: lts/hydrogen
registry-url: https://registry.npmjs.org/
- run: npm install
- run: npm publish --access=public --tag unstable

View File

@ -2,12 +2,15 @@ name: 'pr-platform'
on:
workflow_dispatch:
pull_request:
types: [labeled]
types:
- labeled
- opened
jobs:
e2e-full:
if: ${{ github.event.label.name == 'pr:platform' }}
pr-platform:
if: github.event.label.name == 'pr:platform' || github.event.action == 'opened' && github.actor == 'dependabot[bot]'
runs-on: ${{ matrix.os }}
timeout-minutes: 60
strategy:
fail-fast: false
matrix:
@ -16,18 +19,49 @@ jobs:
- macos-latest
- windows-latest
node_version:
- 16
- 18
- lts/gallium
- lts/hydrogen
architecture:
- x64
name: Node ${{ matrix.node_version }} - ${{ matrix.architecture }} on ${{ matrix.os }}
steps:
- uses: actions/checkout@v3
- name: Setup node
uses: actions/setup-node@v3
with:
node-version: ${{ matrix.node_version }}
architecture: ${{ matrix.architecture }}
- run: npm install
- name: Cache NPM dependencies
uses: actions/cache@v3
with:
path: ~/.npm
key: ${{ runner.os }}-${{ matrix.node_version }}-${{ hashFiles('**/package.json') }}
restore-keys: |
${{ runner.os }}-${{ matrix.node_version }}-
- run: npm install --cache ~/.npm --prefer-offline --no-audit --progress=false
- run: npm test
- run: npm run lint -- --quiet
- name: Remove pr:platform label (if present)
if: always()
uses: actions/github-script@v6
with:
script: |
const { owner, repo, number } = context.issue;
const labelToRemove = 'pr:platform';
try {
await github.rest.issues.removeLabel({
owner,
repo,
issue_number: number,
name: labelToRemove
});
} catch (error) {
core.warning(`Failed to remove ' + labelToRemove + ' label: ${error.message}`);
}

View File

@ -1,5 +1,6 @@
{
"trailingComma": "none",
"singleQuote": true,
"printWidth": 100
"printWidth": 100,
"endOfLine": "auto"
}

View File

@ -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')
}
},

View File

@ -0,0 +1,122 @@
/*****************************************************************************
* Open MCT, Copyright (c) 2014-2023, United States Government
* as represented by the Administrator of the National Aeronautics and Space
* Administration. All rights reserved.
*
* Open MCT is licensed under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0.
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
* WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
* License for the specific language governing permissions and limitations
* under the License.
*
* Open MCT includes source code licensed under additional open source
* licenses. See the Open Source Licenses file (LICENSES.md) included with
* this source code distribution or the Licensing information page available
* at runtime from the About dialog for additional information.
*****************************************************************************/
const { test, expect } = require('../../../pluginFixtures');
const { createDomainObjectWithDefaults, createPlanFromJSON } = require('../../../appActions');
const testPlan = {
TEST_GROUP: [
{
name: 'Past event 1',
start: 1660320408000,
end: 1660343797000,
type: 'TEST-GROUP',
color: 'orange',
textColor: 'white'
},
{
name: 'Past event 2',
start: 1660406808000,
end: 1660429160000,
type: 'TEST-GROUP',
color: 'orange',
textColor: 'white'
},
{
name: 'Past event 3',
start: 1660493208000,
end: 1660503981000,
type: 'TEST-GROUP',
color: 'orange',
textColor: 'white'
},
{
name: 'Past event 4',
start: 1660579608000,
end: 1660624108000,
type: 'TEST-GROUP',
color: 'orange',
textColor: 'white'
},
{
name: 'Past event 5',
start: 1660666008000,
end: 1660681529000,
type: 'TEST-GROUP',
color: 'orange',
textColor: 'white'
}
]
};
test.describe('Time List', () => {
test('Create a Time List, add a single Plan to it and verify all the activities are displayed with no milliseconds', async ({
page
}) => {
// Goto baseURL
await page.goto('./', { waitUntil: 'domcontentloaded' });
const timelist = await test.step('Create a Time List', async () => {
const createdTimeList = await createDomainObjectWithDefaults(page, { type: 'Time List' });
const objectName = await page.locator('.l-browse-bar__object-name').innerText();
expect(objectName).toBe(createdTimeList.name);
return createdTimeList;
});
await test.step('Create a Plan and add it to the timelist', async () => {
const createdPlan = await createPlanFromJSON(page, {
name: 'Test Plan',
json: testPlan
});
await page.goto(timelist.url);
// Expand the tree to show the plan
await page.click("button[title='Show selected item in tree']");
await page.dragAndDrop(`role=treeitem[name=/${createdPlan.name}/]`, '.c-object-view');
await page.click("button[title='Save']");
await page.click("li[title='Save and Finish Editing']");
const startBound = testPlan.TEST_GROUP[0].start;
const endBound = testPlan.TEST_GROUP[testPlan.TEST_GROUP.length - 1].end;
// Switch to fixed time mode with all plan events within the bounds
await page.goto(
`${timelist.url}?tc.mode=fixed&tc.startBound=${startBound}&tc.endBound=${endBound}&tc.timeSystem=utc&view=timelist.view`
);
// Verify all events are displayed
const eventCount = await page.locator('.js-list-item').count();
expect(eventCount).toEqual(testPlan.TEST_GROUP.length);
});
await test.step('Does not show milliseconds in times', async () => {
// Get the first activity
const row = await page.locator('.js-list-item').first();
// Verify that none fo the times have milliseconds displayed.
// Example: 2024-11-17T16:00:00Z is correct and 2024-11-17T16:00:00.000Z is wrong
await expect(row.locator('.--start')).not.toContainText('.');
await expect(row.locator('.--end')).not.toContainText('.');
await expect(row.locator('.--duration')).not.toContainText('.');
});
});
});

View File

@ -205,6 +205,71 @@ test.describe('Display Layout', () => {
expect(await page.locator('.l-layout .l-layout__frame').count()).toEqual(0);
});
test('When multiple plots are contained in a layout, we only ask for annotations once @couchdb', async ({
page
}) => {
// Create another Sine Wave Generator
const anotherSineWaveObject = await createDomainObjectWithDefaults(page, {
type: 'Sine Wave Generator'
});
// Create a Display Layout
await createDomainObjectWithDefaults(page, {
type: 'Display Layout',
name: 'Test Display Layout'
});
// Edit Display Layout
await page.locator('[title="Edit"]').click();
// Expand the 'My Items' folder in the left tree
await page.locator('.c-tree__item__view-control.c-disclosure-triangle').click();
// Add the Sine Wave Generator to the Display Layout and save changes
const treePane = page.getByRole('tree', {
name: 'Main Tree'
});
const sineWaveGeneratorTreeItem = treePane.getByRole('treeitem', {
name: new RegExp(sineWaveObject.name)
});
let layoutGridHolder = page.locator('.l-layout__grid-holder');
// eslint-disable-next-line playwright/no-force-option
await sineWaveGeneratorTreeItem.dragTo(layoutGridHolder, { force: true });
await page.getByText('View type').click();
await page.getByText('Overlay Plot').click();
const anotherSineWaveGeneratorTreeItem = treePane.getByRole('treeitem', {
name: new RegExp(anotherSineWaveObject.name)
});
layoutGridHolder = page.locator('.l-layout__grid-holder');
// eslint-disable-next-line playwright/no-force-option
await anotherSineWaveGeneratorTreeItem.dragTo(layoutGridHolder, { force: true });
await page.getByText('View type').click();
await page.getByText('Overlay Plot').click();
await page.locator('button[title="Save"]').click();
await page.locator('text=Save and Finish Editing').click();
// Time to inspect some network traffic
let networkRequests = [];
page.on('request', (request) => {
const searchRequest = request.url().endsWith('_find');
const fetchRequest = request.resourceType() === 'fetch';
if (searchRequest && fetchRequest) {
networkRequests.push(request);
}
});
await page.reload();
// wait for annotations requests to be batched and requested
await page.waitForLoadState('networkidle');
// Network requests for the composite telemetry with multiple items should be:
// 1. a single batched request for annotations
expect(networkRequests.length).toBe(1);
});
});
/**

View File

@ -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);
});
@ -185,24 +216,7 @@ test.describe('Example Imagery in Display Layout', () => {
displayLayout = await createDomainObjectWithDefaults(page, { type: 'Display Layout' });
await page.goto(displayLayout.url);
/* Create Sine Wave Generator with minimum Image Load Delay */
// Click the Create button
await page.click('button:has-text("Create")');
// Click text=Example Imagery
await page.click('li[role="menuitem"]:has-text("Example Imagery")');
// Clear and set Image load delay to minimum value
await page.locator('input[type="number"]').fill('');
await page.locator('input[type="number"]').fill('5000');
// Click text=OK
await Promise.all([
page.waitForNavigation({ waitUntil: 'networkidle' }),
page.click('button:has-text("OK")'),
//Wait for Save Banner to appear
page.waitForSelector('.c-message-banner__message')
]);
await createImageryView(page);
await expect(page.locator('.l-browse-bar__object-name')).toContainText(
'Unnamed Example Imagery'
@ -315,9 +329,47 @@ test.describe('Example Imagery in Display Layout', () => {
await page.locator('div[title="Resize object height"] > input').click();
await page.locator('div[title="Resize object height"] > input').fill('100');
expect(thumbsWrapperLocator.isVisible()).toBeTruthy();
await expect(thumbsWrapperLocator).toBeVisible();
await expect(thumbsWrapperLocator).not.toHaveClass(/is-small-thumbs/);
});
/**
* Toggle layer visibility checkbox by clicking on checkbox label
* - should toggle checkbox and layer visibility for that image view
* - should NOT toggle checkbox and layer visibity for the first image view in display
*/
test('Toggle layer visibility by clicking on label', async ({ page }) => {
test.info().annotations.push({
type: 'issue',
description: 'https://github.com/nasa/openmct/issues/6709'
});
await createImageryView(page);
await page.goto(displayLayout.url);
const imageElements = page.locator('.c-imagery__main-image-wrapper');
await expect(imageElements).toHaveCount(2);
const imageOne = page.locator('.c-imagery__main-image-wrapper').nth(0);
const imageTwo = page.locator('.c-imagery__main-image-wrapper').nth(1);
const imageOneWrapper = imageOne.locator('.image-wrapper');
const imageTwoWrapper = imageTwo.locator('.image-wrapper');
await imageTwo.hover();
await imageTwo.locator('button[title="Layers"]').click();
const imageTwoLayersMenuContent = imageTwo.locator('button[title="Layers"] + div');
const imageTwoLayersToggleLabel = imageTwoLayersMenuContent.locator('label').last();
await imageTwoLayersToggleLabel.click();
const imageOneLayers = imageOneWrapper.locator('.layer-image');
const imageTwoLayers = imageTwoWrapper.locator('.layer-image');
await expect(imageOneLayers).toHaveCount(0);
await expect(imageTwoLayers).toHaveCount(1);
});
});
test.describe('Example Imagery in Flexible layout', () => {
@ -692,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();
@ -703,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) {
@ -819,3 +870,26 @@ async function resetImageryPanAndZoom(page) {
await panZoomResetBtn.click();
await waitForAnimations(backgroundImage);
}
/**
* @param {import('@playwright/test').Page} page
*/
async function createImageryView(page) {
// Click the Create button
await page.click('button:has-text("Create")');
// Click text=Example Imagery
await page.click('li[role="menuitem"]:has-text("Example Imagery")');
// Clear and set Image load delay to minimum value
await page.locator('input[type="number"]').fill('');
await page.locator('input[type="number"]').fill('5000');
// Click text=OK
await Promise.all([
page.waitForNavigation({ waitUntil: 'networkidle' }),
page.click('button:has-text("OK")'),
//Wait for Save Banner to appear
page.waitForSelector('.c-message-banner__message')
]);
}

View File

@ -1,16 +1,16 @@
{
"name": "openmct",
"version": "2.2.5-SNAPSHOT",
"version": "2.2.6-SNAPSHOT",
"description": "The Open MCT core platform",
"devDependencies": {
"@babel/eslint-parser": "7.21.8",
"@babel/eslint-parser": "7.22.5",
"@braintree/sanitize-url": "6.0.2",
"@deploysentinel/playwright": "0.3.4",
"@percy/cli": "1.24.2",
"@percy/cli": "1.26.0",
"@percy/playwright": "1.0.4",
"@playwright/test": "1.32.3",
"@types/eventemitter3": "1.2.0",
"@types/jasmine": "4.3.1",
"@types/jasmine": "4.3.4",
"@types/lodash": "4.14.192",
"babel-loader": "9.1.0",
"babel-plugin-istanbul": "6.1.1",
@ -21,15 +21,16 @@
"d3-axis": "3.0.0",
"d3-scale": "3.3.0",
"d3-selection": "3.0.0",
"eslint": "8.42.0",
"eslint": "8.43.0",
"eslint-plugin-compat": "4.1.4",
"eslint-config-prettier": "8.8.0",
"eslint-plugin-playwright": "0.12.0",
"eslint-plugin-prettier": "4.2.1",
"eslint-plugin-vue": "9.14.1",
"eslint-plugin-vue": "9.15.0",
"eslint-plugin-you-dont-need-lodash-underscore": "6.12.0",
"eventemitter3": "1.2.0",
"file-saver": "2.0.5",
"flatbush": "4.2.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",
@ -59,20 +59,20 @@
"prettier": "2.8.7",
"printj": "1.3.1",
"resolve-url-loader": "5.0.0",
"sanitize-html": "2.10.0",
"sass": "1.62.1",
"sass-loader": "13.3.1",
"sanitize-html": "2.11.0",
"sass": "1.63.4",
"sass-loader": "13.3.2",
"sinon": "15.1.0",
"style-loader": "3.3.3",
"typescript": "5.1.3",
"uuid": "9.0.0",
"vue": "2.6.14",
"vue-eslint-parser": "9.3.0",
"vue-eslint-parser": "9.3.1",
"vue-loader": "15.9.8",
"vue-template-compiler": "2.6.14",
"webpack": "5.85.1",
"webpack": "5.88.0",
"webpack-cli": "5.1.1",
"webpack-dev-server": "4.13.3",
"webpack-dev-server": "4.15.1",
"webpack-merge": "5.9.0"
},
"scripts": {
@ -111,7 +111,7 @@
"url": "https://github.com/nasa/openmct.git"
},
"engines": {
"node": ">=16.19.1"
"node": ">=16.19.1 <20"
},
"browserslist": [
"Firefox ESR",

View File

@ -76,6 +76,9 @@ const ANNOTATION_LAST_CREATED = 'annotationLastCreated';
* @constructor
*/
export default class AnnotationAPI extends EventEmitter {
/** @type {Map<ANNOTATION_TYPES, Array<(a, b) => boolean >>} */
#targetComparatorMap;
/**
* @param {OpenMCT} openmct
*/
@ -84,6 +87,7 @@ export default class AnnotationAPI extends EventEmitter {
this.openmct = openmct;
this.availableTags = {};
this.namespaceToSaveAnnotations = '';
this.#targetComparatorMap = new Map();
this.ANNOTATION_TYPES = ANNOTATION_TYPES;
this.ANNOTATION_TYPE = ANNOTATION_TYPE;
@ -246,15 +250,16 @@ export default class AnnotationAPI extends EventEmitter {
/**
* @method getAnnotations
* @param {Identifier} domainObjectIdentifier - The domain object identifier to use to search for annotations. For example, a telemetry object identifier.
* @param {AbortSignal} abortSignal - An abort signal to cancel the search
* @returns {DomainObject[]} Returns an array of annotations that match the search query
*/
async getAnnotations(domainObjectIdentifier) {
async getAnnotations(domainObjectIdentifier, abortSignal = null) {
const keyStringQuery = this.openmct.objects.makeKeyString(domainObjectIdentifier);
const searchResults = (
await Promise.all(
this.openmct.objects.search(
keyStringQuery,
null,
abortSignal,
this.openmct.objects.SEARCH_TYPES.ANNOTATIONS
)
)
@ -384,7 +389,8 @@ export default class AnnotationAPI extends EventEmitter {
const combinedResults = [];
results.forEach((currentAnnotation) => {
const existingAnnotation = combinedResults.find((annotationToFind) => {
return _.isEqual(currentAnnotation.targets, annotationToFind.targets);
const { annotationType, targets } = currentAnnotation;
return this.areAnnotationTargetsEqual(annotationType, targets, annotationToFind.targets);
});
if (!existingAnnotation) {
combinedResults.push(currentAnnotation);
@ -460,4 +466,35 @@ export default class AnnotationAPI extends EventEmitter {
return breakApartSeparateTargets;
}
/**
* Adds a comparator function for a given annotation type.
* The comparator functions will be used to determine if two annotations
* have the same target.
* @param {ANNOTATION_TYPES} annotationType
* @param {(t1, t2) => boolean} comparator
*/
addTargetComparator(annotationType, comparator) {
const comparatorList = this.#targetComparatorMap.get(annotationType) ?? [];
comparatorList.push(comparator);
this.#targetComparatorMap.set(annotationType, comparatorList);
}
/**
* Compare two sets of targets to see if they are equal. First checks if
* any targets comparators evaluate to true, then falls back to a deep
* equality check.
* @param {ANNOTATION_TYPES} annotationType
* @param {*} targets
* @param {*} otherTargets
* @returns true if the targets are equal, false otherwise
*/
areAnnotationTargetsEqual(annotationType, targets, otherTargets) {
const targetComparatorList = this.#targetComparatorMap.get(annotationType);
return (
(targetComparatorList?.length &&
targetComparatorList.some((targetComparator) => targetComparator(targets, otherTargets))) ||
_.isEqual(targets, otherTargets)
);
}
}

View File

@ -265,4 +265,52 @@ describe('The Annotation API', () => {
expect(results.length).toEqual(0);
});
});
describe('Target Comparators', () => {
let targets;
let otherTargets;
let comparator;
beforeEach(() => {
targets = {
fooTarget: {
foo: 42
}
};
otherTargets = {
fooTarget: {
bar: 42
}
};
comparator = (t1, t2) => t1.fooTarget.foo === t2.fooTarget.bar;
});
it('can add a comparator function', () => {
const notebookAnnotationType = openmct.annotation.ANNOTATION_TYPES.NOTEBOOK;
expect(
openmct.annotation.areAnnotationTargetsEqual(notebookAnnotationType, targets, otherTargets)
).toBeFalse(); // without a comparator, these should NOT be equal
// Register a comparator function for the notebook annotation type
openmct.annotation.addTargetComparator(notebookAnnotationType, comparator);
expect(
openmct.annotation.areAnnotationTargetsEqual(notebookAnnotationType, targets, otherTargets)
).toBeTrue(); // the comparator should make these equal
});
it('falls back to deep equality check if no comparator functions', () => {
const annotationTypeWithoutComparator = openmct.annotation.ANNOTATION_TYPES.GEOSPATIAL;
const areEqual = openmct.annotation.areAnnotationTargetsEqual(
annotationTypeWithoutComparator,
targets,
targets
);
const areNotEqual = openmct.annotation.areAnnotationTargetsEqual(
annotationTypeWithoutComparator,
targets,
otherTargets
);
expect(areEqual).toBeTrue();
expect(areNotEqual).toBeFalse();
});
});
});

View File

@ -28,6 +28,36 @@ import TelemetryValueFormatter from './TelemetryValueFormatter';
import DefaultMetadataProvider from './DefaultMetadataProvider';
import objectUtils from 'objectUtils';
/**
* @typedef {import('../time/TimeContext').TimeContext} TimeContext
*/
/**
* Describes and bounds requests for telemetry data.
*
* @typedef TelemetryRequestOptions
* @property {String} [sort] the key of the property to sort by. This may
* be prefixed with a "+" or a "-" sign to sort in ascending
* or descending order respectively. If no prefix is present,
* ascending order will be used.
* @property {Number} [start] the lower bound for values of the sorting property
* @property {Number} [end] the upper bound for values of the sorting property
* @property {String} [strategy] symbolic identifier for strategies
* (such as `latest` or `minmax`) which may be recognized by providers;
* these will be tried in order until an appropriate provider
* is found
* @property {AbortController} [signal] an AbortController which can be used
* to cancel a telemetry request
* @property {String} [domain] the domain key of the request
* @property {TimeContext} [timeContext] the time context to use for this request
* @memberof module:openmct.TelemetryAPI~
*/
/**
* Utilities for telemetry
* @interface TelemetryAPI
* @memberof module:openmct
*/
export default class TelemetryAPI {
#isGreedyLAD;
@ -169,25 +199,35 @@ export default class TelemetryAPI {
}
/**
* @private
* Though used in TelemetryCollection as well
* @param {TelemetryRequestOptions} options options for the telemetry request
* @returns {TelemetryRequestOptions} the options, with defaults filled in
*/
standardizeRequestOptions(options) {
if (!Object.prototype.hasOwnProperty.call(options, 'start')) {
options.start = this.openmct.time.bounds().start;
standardizeRequestOptions(options = {}) {
if (!Object.hasOwn(options, 'start')) {
if (options.timeContext?.bounds()) {
options.start = options.timeContext.bounds().start;
} else {
options.start = this.openmct.time.bounds().start;
}
}
if (!Object.prototype.hasOwnProperty.call(options, 'end')) {
options.end = this.openmct.time.bounds().end;
if (!Object.hasOwn(options, 'end')) {
if (options.timeContext?.bounds()) {
options.end = options.timeContext.bounds().end;
} else {
options.end = this.openmct.time.bounds().end;
}
}
if (!Object.prototype.hasOwnProperty.call(options, 'domain')) {
if (!Object.hasOwn(options, 'domain')) {
options.domain = this.openmct.time.timeSystem().key;
}
if (!Object.prototype.hasOwnProperty.call(options, 'timeContext')) {
if (!Object.hasOwn(options, 'timeContext')) {
options.timeContext = this.openmct.time;
}
return options;
}
/**
@ -265,7 +305,7 @@ export default class TelemetryAPI {
* @memberof module:openmct.TelemetryAPI~TelemetryProvider#
* @param {module:openmct.DomainObject} domainObject the object
* which has associated telemetry
* @param {module:openmct.TelemetryAPI~TelemetryRequest} options
* @param {TelemetryRequestOptions} options
* options for this telemetry collection request
* @returns {TelemetryCollection} a TelemetryCollection instance
*/
@ -283,7 +323,7 @@ export default class TelemetryAPI {
* @memberof module:openmct.TelemetryAPI~TelemetryProvider#
* @param {module:openmct.DomainObject} domainObject the object
* which has associated telemetry
* @param {module:openmct.TelemetryAPI~TelemetryRequest} options
* @param {TelemetryRequestOptions} options
* options for this historical request
* @returns {Promise.<object[]>} a promise for an array of
* telemetry data
@ -339,6 +379,7 @@ export default class TelemetryAPI {
* @memberof module:openmct.TelemetryAPI~TelemetryProvider#
* @param {module:openmct.DomainObject} domainObject the object
* which has associated telemetry
* @param {TelemetryRequestOptions} options configuration items for subscription
* @param {Function} callback the callback to invoke with new data, as
* it becomes available
* @returns {Function} a function which may be called to terminate

View File

@ -24,6 +24,22 @@ import _ from 'lodash';
import EventEmitter from 'EventEmitter';
import { LOADED_ERROR, TIMESYSTEM_KEY_NOTIFICATION, TIMESYSTEM_KEY_WARNING } from './constants';
/**
* @typedef {import('../objects/ObjectAPI').DomainObject} DomainObject
*/
/**
* @typedef {import('../time/TimeContext').TimeContext} TimeContext
*/
/**
* @typedef {import('./TelemetryAPI').TelemetryRequestOptions} TelemetryRequestOptions
*/
/**
* @typedef {import('../../../openmct').OpenMCT} OpenMCT
*/
/** Class representing a Telemetry Collection. */
export default class TelemetryCollection extends EventEmitter {
@ -31,10 +47,10 @@ export default class TelemetryCollection extends EventEmitter {
* Creates a Telemetry Collection
*
* @param {OpenMCT} openmct - Open MCT
* @param {module:openmct.DomainObject} domainObject - Domain Object to use for telemetry collection
* @param {object} options - Any options passed in for request/subscribe
* @param {DomainObject} domainObject - Domain Object to use for telemetry collection
* @param {TelemetryRequestOptions} options - Any options passed in for request/subscribe
*/
constructor(openmct, domainObject, options) {
constructor(openmct, domainObject, options = {}) {
super();
this.loaded = false;
@ -45,7 +61,7 @@ export default class TelemetryCollection extends EventEmitter {
this.parseTime = undefined;
this.metadata = this.openmct.telemetry.getMetadata(domainObject);
this.unsubscribe = undefined;
this.options = options;
this.options = this.openmct.telemetry.standardizeRequestOptions(options);
this.pageState = undefined;
this.lastBounds = undefined;
this.requestAbort = undefined;
@ -62,8 +78,8 @@ export default class TelemetryCollection extends EventEmitter {
this._error(LOADED_ERROR);
}
this._setTimeSystem(this.openmct.time.timeSystem());
this.lastBounds = this.openmct.time.bounds();
this._setTimeSystem(this.options.timeContext.timeSystem());
this.lastBounds = this.options.timeContext.bounds();
this._watchBounds();
this._watchTimeSystem();
@ -106,10 +122,10 @@ export default class TelemetryCollection extends EventEmitter {
*/
async _requestHistoricalTelemetry() {
let options = { ...this.options };
let historicalProvider;
this.openmct.telemetry.standardizeRequestOptions(options);
historicalProvider = this.openmct.telemetry.findRequestProvider(this.domainObject, options);
const historicalProvider = this.openmct.telemetry.findRequestProvider(
this.domainObject,
options
);
if (!historicalProvider) {
return;
@ -438,7 +454,7 @@ export default class TelemetryCollection extends EventEmitter {
* @private
*/
_watchBounds() {
this.openmct.time.on('bounds', this._bounds, this);
this.options.timeContext.on('bounds', this._bounds, this);
}
/**
@ -446,7 +462,7 @@ export default class TelemetryCollection extends EventEmitter {
* @private
*/
_unwatchBounds() {
this.openmct.time.off('bounds', this._bounds, this);
this.options.timeContext.off('bounds', this._bounds, this);
}
/**
@ -454,7 +470,7 @@ export default class TelemetryCollection extends EventEmitter {
* @private
*/
_watchTimeSystem() {
this.openmct.time.on('timeSystem', this._setTimeSystemAndFetchData, this);
this.options.timeContext.on('timeSystem', this._setTimeSystemAndFetchData, this);
}
/**
@ -462,7 +478,7 @@ export default class TelemetryCollection extends EventEmitter {
* @private
*/
_unwatchTimeSystem() {
this.openmct.time.off('timeSystem', this._setTimeSystemAndFetchData, this);
this.options.timeContext.off('timeSystem', this._setTimeSystemAndFetchData, this);
}
/**

View File

@ -0,0 +1,423 @@
<!--
Open MCT, Copyright (c) 2014-2023, United States Government
as represented by the Administrator of the National Aeronautics and Space
Administration. All rights reserved.
Open MCT is licensed under the Apache License, Version 2.0 (the
"License"); you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0.
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
License for the specific language governing permissions and limitations
under the License.
Open MCT includes source code licensed under additional open source
licenses. See the Open Source Licenses file (LICENSES.md) included with
this source code distribution or the Licensing information page available
at runtime from the About dialog for additional information.
-->
<template>
<canvas
ref="canvas"
class="c-image-canvas"
style="width: 100%; height: 100%"
@mousedown="clearSelectedAnnotations"
@mousemove="trackAnnotationDrag"
@click="selectOrCreateAnnotation"
></canvas>
</template>
<script>
import Flatbush from 'flatbush';
const EXISTING_ANNOTATION_STROKE_STYLE = '#D79078';
const EXISTING_ANNOTATION_FILL_STYLE = 'rgba(202, 202, 142, 0.2)';
const SELECTED_ANNOTATION_STROKE_COLOR = '#BD8ECC';
const SELECTED_ANNOTATION_FILL_STYLE = 'rgba(199, 87, 231, 0.2)';
export default {
inject: ['openmct', 'domainObject', 'objectPath'],
props: {
image: {
type: Object,
required: true
},
imageryAnnotations: {
type: Array,
default() {
return [];
}
}
},
data() {
return {
dragging: false,
mouseDown: false,
newAnnotationRectangle: {},
keyString: null,
context: null,
canvas: null,
selectedAnnotations: [],
indexToAnnotationMap: {}
};
},
computed: {
annotationsIndex() {
if (this.imageryAnnotations.length) {
// create a flatbush index for the annotations
const builtAnnotationsIndex = new Flatbush(this.imageryAnnotations.length);
this.imageryAnnotations.forEach((annotation) => {
const annotationRectangle = annotation.targets[this.keyString].rectangle;
const annotationRectangleForPixelDepth =
this.transformRectangleToPixelDense(annotationRectangle);
const indexNumber = builtAnnotationsIndex.add(
annotationRectangleForPixelDepth.x,
annotationRectangleForPixelDepth.y,
annotationRectangleForPixelDepth.x + annotationRectangleForPixelDepth.width,
annotationRectangleForPixelDepth.y + annotationRectangleForPixelDepth.height
);
this.indexToAnnotationMap[indexNumber] = annotation;
});
builtAnnotationsIndex.finish();
return builtAnnotationsIndex;
} else {
return null;
}
}
},
watch: {
imageryAnnotations() {
this.drawAnnotations();
}
},
mounted() {
this.canvas = this.$refs.canvas;
this.context = this.canvas.getContext('2d');
// adjust canvas size for retina displays
const pixelScale = window.devicePixelRatio;
this.canvas.width = Math.floor(this.canvas.width * pixelScale);
this.canvas.height = Math.floor(this.canvas.height * pixelScale);
this.keyString = this.openmct.objects.makeKeyString(this.domainObject.identifier);
this.openmct.selection.on('change', this.updateSelection);
this.drawAnnotations();
},
beforeDestroy() {
this.openmct.selection.off('change', this.updateSelection);
document.body.removeEventListener('click', this.cancelSelection);
},
methods: {
onAnnotationChange(annotations) {
this.selectedAnnotations = annotations;
this.$emit('annotationsChanged', annotations);
},
updateSelection(selection) {
const selectionContext = selection?.[0]?.[0]?.context?.item;
const selectionType = selection?.[0]?.[0]?.context?.type;
const validSelectionTypes = ['clicked-on-image-selection'];
if (!validSelectionTypes.includes(selectionType)) {
// wrong type of selection
return;
}
if (
selectionContext &&
this.openmct.objects.areIdsEqual(selectionContext.identifier, this.domainObject.identifier)
) {
return;
}
const incomingSelectedAnnotations = selection?.[0]?.[0]?.context?.annotations;
this.prepareExistingAnnotationSelection(incomingSelectedAnnotations);
},
prepareExistingAnnotationSelection(annotations) {
const targetDomainObjects = {};
targetDomainObjects[this.keyString] = this.domainObject;
const targetDetails = {};
annotations.forEach((annotation) => {
Object.entries(annotation.targets).forEach(([key, value]) => {
targetDetails[key] = value;
});
});
this.selectedAnnotations = annotations;
this.drawAnnotations();
return {
targetDomainObjects,
targetDetails
};
},
clearSelectedAnnotations() {
if (!this.openmct.annotation.getAvailableTags().length) {
// don't bother with new annotations if there are no tags
return;
}
this.mouseDown = true;
this.selectedAnnotations = [];
},
/**
* Given a rectangle, returns a rectangle that conforms to the pixel density of the device
* @param {Object} rectangle without pixel density applied
* @returns {Object} transformed rectangle with pixel density applied
*/
transformRectangleToPixelDense(rectangle) {
const pixelScale = window.devicePixelRatio;
const transformedRectangle = {
x: rectangle.x * pixelScale,
y: rectangle.y * pixelScale,
width: rectangle.width * pixelScale,
height: rectangle.height * pixelScale
};
return transformedRectangle;
},
/**
* Given a rectangle, returns a rectangle that is independent of the pixel density of the device
* @param {Object} rectangle with pixel density applied
* @returns {Object} transformed rectangle without pixel density applied
*/
transformRectangleFromPixelDense(rectangle) {
const pixelScale = window.devicePixelRatio;
const transformedRectangle = {
x: rectangle.x / pixelScale,
y: rectangle.y / pixelScale,
width: rectangle.width / pixelScale,
height: rectangle.height / pixelScale
};
return transformedRectangle;
},
drawRectInCanvas(rectangle, fillStyle, strokeStyle) {
this.context.beginPath();
this.context.lineWidth = 1;
this.context.fillStyle = fillStyle;
this.context.strokeStyle = strokeStyle;
this.context.rect(rectangle.x, rectangle.y, rectangle.width, rectangle.height);
this.context.fill();
this.context.stroke();
},
trackAnnotationDrag(event) {
if (this.mouseDown && !this.dragging && event.shiftKey && event.altKey) {
this.startAnnotationDrag(event);
} else if (this.dragging) {
const boundingRect = this.canvas.getBoundingClientRect();
const scaleX = this.canvas.width / boundingRect.width;
const scaleY = this.canvas.height / boundingRect.height;
this.newAnnotationRectangle = {
x: this.newAnnotationRectangle.x,
y: this.newAnnotationRectangle.y,
width: (event.clientX - boundingRect.left) * scaleX - this.newAnnotationRectangle.x,
height: (event.clientY - boundingRect.top) * scaleY - this.newAnnotationRectangle.y
};
this.drawAnnotations();
this.drawRectInCanvas(
this.newAnnotationRectangle,
SELECTED_ANNOTATION_FILL_STYLE,
SELECTED_ANNOTATION_STROKE_COLOR
);
}
},
clearCanvas() {
this.context.clearRect(0, 0, this.canvas.width, this.canvas.height);
},
selectImageView() {
// should show ImageView itself if we have no annotations to display
const selection = this.createPathSelection();
this.openmct.selection.select(selection, true);
},
createSelection(annotation) {
const selection = this.createPathSelection();
selection[0].context = annotation;
return selection;
},
selectImageAnnotations({ targetDetails, targetDomainObjects, annotations }) {
const annotationContext = {
type: 'clicked-on-image-selection',
targetDetails,
targetDomainObjects,
annotations,
annotationType: this.openmct.annotation.ANNOTATION_TYPES.PIXEL_SPATIAL,
onAnnotationChange: this.onAnnotationChange
};
const selection = this.createPathSelection();
if (
selection.length &&
this.openmct.objects.areIdsEqual(
selection[0].context.item.identifier,
this.domainObject.identifier
)
) {
selection[0].context = {
...selection[0].context,
...annotationContext
};
} else {
selection.unshift({
element: this.$el,
context: {
item: this.domainObject,
...annotationContext
}
});
}
this.openmct.selection.select(selection, true);
document.body.addEventListener('click', this.cancelSelection);
},
cancelSelection(event) {
if (this.$refs.canvas) {
const clickedInsideCanvas = this.$refs.canvas.contains(event.target);
const clickedInsideInspector = event.target.closest('.js-inspector') !== null;
const clickedOption = event.target.closest('.js-autocomplete-options') !== null;
if (!clickedInsideCanvas && !clickedInsideInspector && !clickedOption) {
this.newAnnotationRectangle = {};
this.selectedAnnotations = [];
this.drawAnnotations();
}
}
},
createNewAnnotation() {
this.dragging = false;
this.selectedAnnotations = [];
const targetDomainObjects = {};
targetDomainObjects[this.keyString] = this.domainObject;
const targetDetails = {};
const rectangleFromCanvas = {
x: this.newAnnotationRectangle.x,
y: this.newAnnotationRectangle.y,
width: this.newAnnotationRectangle.width,
height: this.newAnnotationRectangle.height
};
const rectangleWithoutPixelScale = this.transformRectangleFromPixelDense(rectangleFromCanvas);
targetDetails[this.keyString] = {
rectangle: rectangleWithoutPixelScale,
time: this.image.time
};
this.selectImageAnnotations({
targetDetails,
targetDomainObjects,
annotations: []
});
},
attemptToSelectExistingAnnotation(event) {
this.dragging = false;
// use flatbush to find annotations that are close to the click
const boundingRect = this.canvas.getBoundingClientRect();
const scaleX = this.canvas.width / boundingRect.width;
const scaleY = this.canvas.height / boundingRect.height;
const x = (event.clientX - boundingRect.left) * scaleX;
const y = (event.clientY - boundingRect.top) * scaleY;
if (this.annotationsIndex) {
let nearbyAnnotations = [];
const resultIndicies = this.annotationsIndex.search(x, y, x, y);
resultIndicies.forEach((resultIndex) => {
const foundAnnotation = this.indexToAnnotationMap[resultIndex];
if (foundAnnotation._deleted) {
return;
}
nearbyAnnotations.push(foundAnnotation);
});
//show annotations if some were found
const { targetDomainObjects, targetDetails } =
this.prepareExistingAnnotationSelection(nearbyAnnotations);
this.selectImageAnnotations({
targetDetails,
targetDomainObjects,
annotations: nearbyAnnotations
});
} else {
// nothing selected
this.drawAnnotations();
}
},
selectOrCreateAnnotation(event) {
event.stopPropagation();
this.mouseDown = false;
if (
!this.dragging ||
(!this.newAnnotationRectangle.width && !this.newAnnotationRectangle.height)
) {
this.newAnnotationRectangle = {};
this.attemptToSelectExistingAnnotation(event);
} else {
this.createNewAnnotation();
}
},
createPathSelection() {
let selection = [];
selection.unshift({
element: this.$el,
context: {
item: this.domainObject
}
});
this.objectPath.forEach((pathObject, index) => {
selection.push({
element: this.openmct.layout.$refs.browseObject.$el,
context: {
item: pathObject
}
});
});
return selection;
},
startAnnotationDrag(event) {
this.$emit('annotationMarqueed');
this.newAnnotationRectangle = {};
const boundingRect = this.canvas.getBoundingClientRect();
const scaleX = this.canvas.width / boundingRect.width;
const scaleY = this.canvas.height / boundingRect.height;
this.newAnnotationRectangle = {
x: (event.clientX - boundingRect.left) * scaleX,
y: (event.clientY - boundingRect.top) * scaleY
};
this.dragging = true;
},
isSelectedAnnotation(annotation) {
const someSelectedAnnotationExists = this.selectedAnnotations.some((selectedAnnotation) => {
return this.openmct.objects.areIdsEqual(
selectedAnnotation.identifier,
annotation.identifier
);
});
return someSelectedAnnotationExists;
},
drawAnnotations() {
this.clearCanvas();
this.imageryAnnotations.forEach((annotation) => {
if (annotation._deleted) {
return;
}
const rectangleForPixelDensity = this.transformRectangleToPixelDense(
annotation.targets[this.keyString].rectangle
);
if (this.isSelectedAnnotation(annotation)) {
this.drawRectInCanvas(
rectangleForPixelDensity,
SELECTED_ANNOTATION_FILL_STYLE,
SELECTED_ANNOTATION_STROKE_COLOR
);
} else {
this.drawRectInCanvas(
rectangleForPixelDensity,
EXISTING_ANNOTATION_FILL_STYLE,
EXISTING_ANNOTATION_STROKE_STYLE
);
}
});
}
}
};
</script>

View File

@ -19,7 +19,7 @@ $elemBg: rgba(black, 0.7);
position: absolute;
left: 0;
top: 0;
z-index: 2;
z-index: 3;
@include userSelectNone;
}

View File

@ -137,7 +137,7 @@ export default {
imageUrl(newUrl, oldUrl) {
// reset image pan/zoom if newUrl only if not locked
if (newUrl && !this.panZoomLocked) {
this.$emit('resetImage');
this.handleResetImage();
}
},
cursorStates(states) {

View File

@ -38,6 +38,11 @@
fetchpriority="low"
@load="imageLoadCompleted"
/>
<i
v-show="showAnnotationIndicator"
class="c-thumb__annotation-indicator icon-status-poll-edit"
>
</i>
</a>
<div v-if="viewableArea" class="c-thumb__viewable-area" :style="viewableAreaStyle"></div>
<div class="c-thumb__timestamp">{{ image.formattedTime }}</div>
@ -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: {

View File

@ -88,6 +88,13 @@
:image="focusedImage"
:sized-image-dimensions="sizedImageDimensions"
/>
<AnnotationsCanvas
v-if="shouldDisplayAnnotations"
:image="focusedImage"
:imagery-annotations="imageryAnnotations[focusedImage.time]"
@annotationMarqueed="handlePauseButton(true)"
@annotationsChanged="loadAnnotations"
/>
</div>
</div>
@ -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) {

View File

@ -27,20 +27,14 @@
<div class="c-checkbox-list js-checkbox-menu c-menu--to-left c-menu--has-close-btn">
<ul @click="$event.stopPropagation()">
<li v-for="(layer, index) in layers" :key="index">
<input
v-if="layer.visible"
:id="index + 'LayerControl'"
checked
type="checkbox"
@change="toggleLayerVisibility(index)"
/>
<input
v-else
:id="index + 'LayerControl'"
type="checkbox"
@change="toggleLayerVisibility(index)"
/>
<label :for="index + 'LayerControl'">{{ layer.name }}</label>
<label>
<input
:checked="layer.visible"
type="checkbox"
@change="toggleLayerVisibility(index)"
/>
{{ layer.name }}
</label>
</li>
</ul>
</div>

View File

@ -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;
}

View File

@ -60,7 +60,9 @@ export default {
this.timeKey = this.timeSystem.key;
this.timeFormatter = this.getFormatter(this.timeKey);
this.telemetryCollection = this.openmct.telemetry.requestCollection(this.domainObject, {});
this.telemetryCollection = this.openmct.telemetry.requestCollection(this.domainObject, {
timeContext: this.timeContext
});
this.telemetryCollection.on('add', this.dataAdded);
this.telemetryCollection.on('remove', this.dataRemoved);
this.telemetryCollection.on('clear', this.dataCleared);

View File

@ -41,7 +41,6 @@
<script>
import TagEditor from './tags/TagEditor.vue';
import _ from 'lodash';
export default {
components: {
@ -123,6 +122,7 @@ export default {
}
},
async mounted() {
this.abortController = null;
this.openmct.annotation.on('targetDomainObjectAnnotated', this.loadAnnotationForTargetObject);
this.openmct.selection.on('change', this.updateSelection);
await this.updateSelection(this.openmct.selection.get());
@ -190,20 +190,34 @@ export default {
}
},
async loadAnnotationForTargetObject(target) {
const targetID = this.openmct.objects.makeKeyString(target.identifier);
const allAnnotationsForTarget = await this.openmct.annotation.getAnnotations(
target.identifier
);
const filteredAnnotationsForSelection = allAnnotationsForTarget.filter((annotation) => {
const matchingTargetID = Object.keys(annotation.targets).filter((loadedTargetID) => {
return targetID === loadedTargetID;
});
const fetchedTargetDetails = annotation.targets[matchingTargetID];
const selectedTargetDetails = this.targetDetails[matchingTargetID];
// If the user changes targets while annotations are loading,
// abort the previous request.
if (this.abortController !== null) {
this.abortController.abort();
}
return _.isEqual(fetchedTargetDetails, selectedTargetDetails);
});
this.loadNewAnnotations(filteredAnnotationsForSelection);
this.abortController = new AbortController();
try {
const allAnnotationsForTarget = await this.openmct.annotation.getAnnotations(
target.identifier,
this.abortController.signal
);
const filteredAnnotationsForSelection = allAnnotationsForTarget.filter((annotation) =>
this.openmct.annotation.areAnnotationTargetsEqual(
this.annotationType,
this.targetDetails,
annotation.targets
)
);
this.loadNewAnnotations(filteredAnnotationsForSelection);
} catch (err) {
if (err.name !== 'AbortError') {
throw err;
}
} finally {
this.abortController = null;
}
}
}
};

View File

@ -306,13 +306,22 @@ export default {
this.getSearchResults = debounce(this.getSearchResults, 500);
this.syncUrlWithPageAndSection = debounce(this.syncUrlWithPageAndSection, 100);
},
async mounted() {
await this.loadAnnotations();
async created() {
this.transaction = null;
this.abortController = new AbortController();
try {
await this.loadAnnotations();
} catch (err) {
if (err.name !== 'AbortError') {
throw err;
}
}
},
mounted() {
this.formatSidebar();
this.setSectionAndPageFromUrl();
this.openmct.selection.on('change', this.updateSelection);
this.transaction = null;
window.addEventListener('orientationchange', this.formatSidebar);
window.addEventListener('hashchange', this.setSectionAndPageFromUrl);
@ -324,6 +333,7 @@ export default {
);
},
beforeDestroy() {
this.abortController.abort();
if (this.unlisten) {
this.unlisten();
}
@ -387,8 +397,10 @@ export default {
this.lastLocalAnnotationCreation = this.domainObject.annotationLastCreated ?? 0;
const foundAnnotations = await this.openmct.annotation.getAnnotations(
this.domainObject.identifier
this.domainObject.identifier,
this.abortController.signal
);
foundAnnotations.forEach((foundAnnotation) => {
const targetId = Object.keys(foundAnnotation.targets)[0];
const entryId = foundAnnotation.targets[targetId].entryId;
@ -425,7 +437,11 @@ export default {
: [...filteredPageEntriesByTime].reverse();
if (this.lastLocalAnnotationCreation < this.domainObject.annotationLastCreated) {
this.loadAnnotations();
this.loadAnnotations().catch((err) => {
if (err.name !== 'AbortError') {
throw err;
}
});
}
},
changeSelectedSection({ sectionId, pageId }) {

View File

@ -27,7 +27,13 @@
// If the above namespace is ever resolved, we can fold this search provider
// back into the object provider.
const BATCH_ANNOTATION_DEBOUNCE_MS = 100;
class CouchSearchProvider {
#bulkPromise;
#batchIds;
#lastAbortSignal;
constructor(couchObjectProvider) {
this.couchObjectProvider = couchObjectProvider;
this.searchTypes = couchObjectProvider.openmct.objects.SEARCH_TYPES;
@ -36,6 +42,8 @@ class CouchSearchProvider {
this.searchTypes.ANNOTATIONS,
this.searchTypes.TAGS
];
this.#batchIds = [];
this.#bulkPromise = null;
}
supportsSearchType(searchType) {
@ -68,28 +76,77 @@ class CouchSearchProvider {
return this.couchObjectProvider.getObjectsByFilter(filter, abortSignal);
}
searchForAnnotations(keyString, abortSignal) {
async #deferBatchAnnotationSearch() {
// We until the next event loop cycle to "collect" all of the get
// requests triggered in this iteration of the event loop
await this.#waitForDebounce();
const batchIdsToSearch = [...this.#batchIds];
this.#clearBatch();
return this.#bulkAnnotationSearch(batchIdsToSearch);
}
#clearBatch() {
this.#batchIds = [];
this.#bulkPromise = undefined;
}
#waitForDebounce() {
let timeoutID;
clearTimeout(timeoutID);
return new Promise((resolve) => {
timeoutID = setTimeout(() => {
resolve();
}, BATCH_ANNOTATION_DEBOUNCE_MS);
});
}
#bulkAnnotationSearch(batchIdsToSearch) {
const filter = {
selector: {
$and: [
{
model: {
targets: {}
}
},
{
'model.type': {
$eq: 'annotation'
}
},
{
$or: []
}
]
}
};
filter.selector.$and[0].model.targets[keyString] = {
$exists: true
};
let lastAbortSignal = null;
// TODO: should remove duplicates from batchIds
batchIdsToSearch.forEach(({ keyString, abortSignal }) => {
const modelFilter = {
model: {
targets: {}
}
};
modelFilter.model.targets[keyString] = {
$exists: true
};
return this.couchObjectProvider.getObjectsByFilter(filter, abortSignal);
filter.selector.$and[1].$or.push(modelFilter);
lastAbortSignal = abortSignal;
});
return this.couchObjectProvider.getObjectsByFilter(filter, lastAbortSignal);
}
async searchForAnnotations(keyString, abortSignal) {
this.#batchIds.push({ keyString, abortSignal });
if (!this.#bulkPromise) {
this.#bulkPromise = this.#deferBatchAnnotationSearch();
}
const returnedData = await this.#bulkPromise;
// only return data that matches the keystring
const filteredByKeyString = returnedData.filter((foundAnnotation) => {
return foundAnnotation.targets[keyString];
});
return filteredByKeyString;
}
searchForTags(tagsArray, abortSignal) {

View File

@ -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;
@ -339,6 +339,9 @@ export default {
this.cursorGuide = newCursorGuide;
}
},
created() {
this.abortController = new AbortController();
},
mounted() {
this.yAxisIdVisibility = {};
this.offsetWidth = 0;
@ -398,6 +401,7 @@ export default {
this.loaded = true;
},
beforeDestroy() {
this.abortController.abort();
this.openmct.selection.off('change', this.updateSelection);
document.removeEventListener('keydown', this.handleKeyDown);
document.removeEventListener('keyup', this.handleKeyUp);
@ -410,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
@ -621,7 +625,8 @@ export default {
await Promise.all(
this.seriesModels.map(async (seriesModel) => {
const seriesAnnotations = await this.openmct.annotation.getAnnotations(
seriesModel.model.identifier
seriesModel.model.identifier,
this.abortController.signal
);
rawAnnotationsForPlot.push(...seriesAnnotations);
})
@ -1393,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 = [];
@ -1408,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) {
@ -1524,7 +1533,11 @@ export default {
this.endMarquee();
}
this.loadAnnotations();
this.loadAnnotations().catch((err) => {
if (err.name !== 'AbortError') {
throw err;
}
});
},
zoom(zoomDirection, zoomFactor) {

View File

@ -41,8 +41,10 @@ import moment from 'moment';
import { v4 as uuid } from 'uuid';
const SCROLL_TIMEOUT = 10000;
const ROW_HEIGHT = 30;
const TIME_FORMAT = 'YYYY-MM-DD HH:mm:ss:SSS';
const TIME_FORMAT = 'YYYY-MM-DD HH:mm:ss';
const CURRENT_CSS_SUFFIX = '--is-current';
const PAST_CSS_SUFFIX = '--is-past';
const FUTURE_CSS_SUFFIX = '--is-future';
const headerItems = [
{
defaultDirection: true,
@ -79,9 +81,9 @@ const headerItems = [
format: function (value) {
let result;
if (value < 0) {
result = `-${getPreciseDuration(Math.abs(value))}`;
result = `+${getPreciseDuration(Math.abs(value), true)}`;
} else if (value > 0) {
result = `+${getPreciseDuration(value)}`;
result = `-${getPreciseDuration(value, true)}`;
} else {
result = 'Now';
}
@ -360,11 +362,12 @@ export default {
groups.forEach((key) => {
activities = activities.concat(this.planData[key]);
});
activities = activities.filter(this.filterActivities);
// filter activities first, then sort by start time
activities = activities.filter(this.filterActivities).sort(this.sortByStartTime);
activities = this.applyStyles(activities);
this.setScrollTop();
// sort by start time
this.planActivities = activities.sort(this.sortByStartTime);
this.planActivities = activities;
//We need to wait for the next tick since we need the height of the row from the DOM
this.$nextTick(this.setScrollTop);
},
updateTimeStampAndListActivities(time) {
this.timestamp = time;
@ -410,30 +413,41 @@ export default {
},
applyStyles(activities) {
let firstCurrentActivityIndex = -1;
let activityClosestToNowIndex = -1;
let currentActivitiesCount = 0;
const styledActivities = activities.map((activity, index) => {
if (this.timestamp >= activity.start && this.timestamp <= activity.end) {
activity.cssClass = '--is-current';
activity.cssClass = CURRENT_CSS_SUFFIX;
if (firstCurrentActivityIndex < 0) {
firstCurrentActivityIndex = index;
}
currentActivitiesCount = currentActivitiesCount + 1;
} else if (this.timestamp < activity.start) {
activity.cssClass = '--is-future';
activity.cssClass = FUTURE_CSS_SUFFIX;
//the index of the first activity that's greater than the current timestamp
if (activityClosestToNowIndex < 0) {
activityClosestToNowIndex = index;
}
} else {
activity.cssClass = '--is-past';
activity.cssClass = PAST_CSS_SUFFIX;
}
if (!activity.key) {
activity.key = uuid();
}
activity.duration = activity.start - this.timestamp;
if (activity.start < this.timestamp) {
//if the activity start time has passed, display the time to the end of the activity
activity.duration = activity.end - this.timestamp;
} else {
activity.duration = activity.start - this.timestamp;
}
return activity;
});
this.activityClosestToNowIndex = activityClosestToNowIndex;
this.firstCurrentActivityIndex = firstCurrentActivityIndex;
this.currentActivitiesCount = currentActivitiesCount;
@ -451,13 +465,22 @@ export default {
}
this.firstCurrentActivityIndex = -1;
this.activityClosestToNowIndex = -1;
this.currentActivitiesCount = 0;
this.$el.parentElement?.scrollTo({ top: 0 });
this.autoScrolled = false;
},
setScrollTop() {
//scroll to somewhere mid-way of the current activities
if (this.firstCurrentActivityIndex > -1) {
//The view isn't ready yet
if (!this.$el.parentElement) {
return;
}
const row = this.$el.querySelector('.js-list-item');
if (row && this.firstCurrentActivityIndex > -1) {
// scroll to somewhere mid-way of the current activities
const ROW_HEIGHT = row.getBoundingClientRect().height;
if (this.canAutoScroll() === false) {
return;
}
@ -469,7 +492,22 @@ export default {
behavior: 'smooth'
});
this.autoScrolled = false;
} else if (row && this.activityClosestToNowIndex > -1) {
// scroll to somewhere close to 'now'
const ROW_HEIGHT = row.getBoundingClientRect().height;
if (this.canAutoScroll() === false) {
return;
}
this.$el.parentElement.scrollTo({
top: ROW_HEIGHT * (this.activityClosestToNowIndex - 1),
behavior: 'smooth'
});
this.autoScrolled = false;
} else {
// scroll to the top
this.resetScroll();
}
},

View File

@ -219,10 +219,10 @@ describe('the plugin', function () {
'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua'
);
expect(itemValues[0].innerHTML.trim()).toEqual(
`${moment(twoHoursPast).format('YYYY-MM-DD HH:mm:ss:SSS')}Z`
`${moment(twoHoursPast).format('YYYY-MM-DD HH:mm:ss')}Z`
);
expect(itemValues[1].innerHTML.trim()).toEqual(
`${moment(oneHourPast).format('YYYY-MM-DD HH:mm:ss:SSS')}Z`
`${moment(oneHourPast).format('YYYY-MM-DD HH:mm:ss')}Z`
);
done();

View File

@ -98,6 +98,13 @@ export default {
}
return 'Could not find any matching Notebook entries';
} else if (
this.result.annotationType === this.openmct.annotation.ANNOTATION_TYPES.GEOSPATIAL
) {
const targetID = Object.keys(this.result.targets)[0];
const { layerName, name } = this.result.targets[targetID];
return layerName ? `${layerName} - ${name}` : name;
} else {
return this.result.targetModels[0].name;
}
@ -115,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) {
@ -132,17 +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.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();
}
}
},
@ -151,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 = {};
@ -168,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: () => {}
}
}

View File

@ -63,14 +63,16 @@ export function millisecondsToDHMS(numericDuration) {
return `${dhms ? '+' : ''} ${dhms}`;
}
export function getPreciseDuration(value) {
export function getPreciseDuration(value, excludeMilliSeconds) {
const ms = value || 0;
return [
const duration = [
toDoubleDigits(Math.floor(normalizeAge(ms / ONE_DAY))),
toDoubleDigits(Math.floor(normalizeAge((ms % ONE_DAY) / ONE_HOUR))),
toDoubleDigits(Math.floor(normalizeAge((ms % ONE_HOUR) / ONE_MINUTE))),
toDoubleDigits(Math.floor(normalizeAge((ms % ONE_MINUTE) / ONE_SECOND))),
toTripleDigits(Math.floor(normalizeAge(ms % ONE_SECOND)))
].join(':');
toDoubleDigits(Math.floor(normalizeAge((ms % ONE_MINUTE) / ONE_SECOND)))
];
if (!excludeMilliSeconds) {
duration.push(toTripleDigits(Math.floor(normalizeAge(ms % ONE_SECOND))));
}
return duration.join(':');
}