Merge branch 'master' into mct6555

This commit is contained in:
Michael Rogers 2023-06-23 16:18:22 -05:00
commit de07e0326c
40 changed files with 1589 additions and 313 deletions

View File

@ -232,9 +232,9 @@ jobs:
workflows: workflows:
overall-circleci-commit-status: #These jobs run on every commit overall-circleci-commit-status: #These jobs run on every commit
jobs: jobs:
# - lint: - lint:
# name: node16-lint name: node16-lint
# node-version: lts/gallium node-version: lts/gallium
- unit-test: - unit-test:
name: node18-chrome name: node18-chrome
node-version: lts/hydrogen node-version: lts/hydrogen

View File

@ -4,3 +4,9 @@
# Requires Git > 2.23 # Requires Git > 2.23
# See https://git-scm.com/docs/git-blame#Documentation/git-blame.txt---ignore-revs-fileltfilegt # See https://git-scm.com/docs/git-blame#Documentation/git-blame.txt---ignore-revs-fileltfilegt
# Copyright year update 2022
4a9744e916d24122a81092f6b7950054048ba860
# Copyright year update 2023
8040b275fcf2ba71b42cd72d4daa64bb25c19c2d
# Apply `prettier` formatting
caa7bc6faebc204f67aedae3e35fb0d0d3ce27a7

View File

@ -7,15 +7,33 @@ on:
- opened - opened
jobs: jobs:
e2e-couchdb: 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 runs-on: ubuntu-latest
timeout-minutes: 60
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v3
- uses: actions/setup-node@v3 - uses: actions/setup-node@v3
with: 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: npx playwright@1.32.3 install
- run: npm install
- name: Start CouchDB Docker Container and Init with Setup Scripts - name: Start CouchDB Docker Container and Init with Setup Scripts
run: | run: |
export $(cat src/plugins/persistence/couch/.env.ci | xargs) export $(cat src/plugins/persistence/couch/.env.ci | xargs)
@ -23,26 +41,31 @@ jobs:
sleep 3 sleep 3
bash src/plugins/persistence/couch/setup-couchdb.sh bash src/plugins/persistence/couch/setup-couchdb.sh
bash src/plugins/persistence/couch/replace-localstorage-with-couchdb-indexhtml.sh bash src/plugins/persistence/couch/replace-localstorage-with-couchdb-indexhtml.sh
- name: Run CouchDB Tests and publish to deploysentinel - name: Run CouchDB Tests and publish to deploysentinel
env: env:
DEPLOYSENTINEL_API_KEY: ${{ secrets.DEPLOYSENTINEL_API_KEY }} DEPLOYSENTINEL_API_KEY: ${{ secrets.DEPLOYSENTINEL_API_KEY }}
run: npm run test:e2e:couchdb run: npm run test:e2e:couchdb
- name: Publish Results to Codecov.io - name: Publish Results to Codecov.io
env: env:
SUPER_SECRET: ${{ secrets.CODECOV_TOKEN }} SUPER_SECRET: ${{ secrets.CODECOV_TOKEN }}
run: npm run cov:e2e:full:publish run: npm run cov:e2e:full:publish
- name: Archive test results - name: Archive test results
if: success() || failure() if: success() || failure()
uses: actions/upload-artifact@v3 uses: actions/upload-artifact@v3
with: with:
path: test-results path: test-results
- name: Archive html test results - name: Archive html test results
if: success() || failure() if: success() || failure()
uses: actions/upload-artifact@v3 uses: actions/upload-artifact@v3
with: with:
path: html-test-results path: html-test-results
- name: Remove pr:e2e:couchdb label (if present) - 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 uses: actions/github-script@v6
with: with:
script: | script: |
@ -56,5 +79,5 @@ jobs:
name: labelToRemove name: labelToRemove
}); });
} catch (error) { } 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 - opened
jobs: jobs:
e2e-full: 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 }} runs-on: ${{ matrix.os }}
timeout-minutes: 60
strategy: strategy:
matrix: matrix:
os: os:
- ubuntu-latest - ubuntu-latest
- windows-latest - windows-latest
steps: 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/checkout@v3
- uses: actions/setup-node@v3 - uses: actions/setup-node@v3
with: 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@1.32.3 install
- run: npx playwright install chrome-beta - 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 test:e2e:full -- --max-failures=40
- run: npm run cov:e2e:report || true - run: npm run cov:e2e:report || true
- shell: bash - shell: bash
@ -44,30 +44,9 @@ jobs:
uses: actions/upload-artifact@v3 uses: actions/upload-artifact@v3
with: with:
path: test-results 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) - name: Remove pr:e2e label (if present)
if: ${{ contains(github.event.pull_request.labels.*.name, 'pr:e2e') }} if: always()
uses: actions/github-script@v6 uses: actions/github-script@v6
with: with:
script: | script: |
@ -81,5 +60,5 @@ jobs:
name: labelToRemove name: labelToRemove
}); });
} catch (error) { } 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/checkout@v3
- uses: actions/setup-node@v3 - uses: actions/setup-node@v3
with: with:
node-version: 16 node-version: lts/hydrogen
- run: npm install - run: npm install
- run: | - run: |
echo "//registry.npmjs.org/:_authToken=$NODE_AUTH_TOKEN" >> ~/.npmrc echo "//registry.npmjs.org/:_authToken=$NODE_AUTH_TOKEN" >> ~/.npmrc
@ -29,7 +29,7 @@ jobs:
- uses: actions/checkout@v3 - uses: actions/checkout@v3
- uses: actions/setup-node@v3 - uses: actions/setup-node@v3
with: with:
node-version: 16 node-version: lts/hydrogen
registry-url: https://registry.npmjs.org/ registry-url: https://registry.npmjs.org/
- run: npm install - run: npm install
- run: npm publish --access=public --tag unstable - run: npm publish --access=public --tag unstable

View File

@ -2,12 +2,15 @@ name: 'pr-platform'
on: on:
workflow_dispatch: workflow_dispatch:
pull_request: pull_request:
types: [labeled] types:
- labeled
- opened
jobs: jobs:
e2e-full: pr-platform:
if: ${{ github.event.label.name == 'pr:platform' }} if: github.event.label.name == 'pr:platform' || github.event.action == 'opened' && github.actor == 'dependabot[bot]'
runs-on: ${{ matrix.os }} runs-on: ${{ matrix.os }}
timeout-minutes: 60
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
@ -16,18 +19,49 @@ jobs:
- macos-latest - macos-latest
- windows-latest - windows-latest
node_version: node_version:
- 16 - lts/gallium
- 18 - lts/hydrogen
architecture: architecture:
- x64 - x64
name: Node ${{ matrix.node_version }} - ${{ matrix.architecture }} on ${{ matrix.os }} name: Node ${{ matrix.node_version }} - ${{ matrix.architecture }} on ${{ matrix.os }}
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v3
- name: Setup node - name: Setup node
uses: actions/setup-node@v3 uses: actions/setup-node@v3
with: with:
node-version: ${{ matrix.node_version }} node-version: ${{ matrix.node_version }}
architecture: ${{ matrix.architecture }} 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 test
- run: npm run lint -- --quiet - 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", "trailingComma": "none",
"singleQuote": true, "singleQuote": true,
"printWidth": 100 "printWidth": 100,
"endOfLine": "auto"
} }

View File

@ -67,7 +67,6 @@ const config = {
MCT: path.join(projectRootDir, 'src/MCT'), MCT: path.join(projectRootDir, 'src/MCT'),
testUtils: path.join(projectRootDir, 'src/utils/testUtils.js'), testUtils: path.join(projectRootDir, 'src/utils/testUtils.js'),
objectUtils: path.join(projectRootDir, 'src/api/objects/object-utils.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') 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); 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 { createDomainObjectWithDefaults } = require('../../../../appActions');
const backgroundImageSelector = '.c-imagery__main-image__background-image'; const backgroundImageSelector = '.c-imagery__main-image__background-image';
const panHotkey = process.platform === 'linux' ? ['Shift', 'Alt'] : ['Alt']; 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 expectedAltText = process.platform === 'linux' ? 'Shift+Alt drag to pan' : 'Alt drag to pan';
const thumbnailUrlParamsRegexp = /\?w=100&h=100/; const thumbnailUrlParamsRegexp = /\?w=100&h=100/;
@ -44,7 +45,7 @@ test.describe('Example Imagery Object', () => {
// Verify that the created object is focused // Verify that the created object is focused
await expect(page.locator('.l-browse-bar__object-name')).toContainText(exampleImagery.name); 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 }) => { 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 }) => { test('Can use alt+drag to move around image once zoomed in', async ({ page }) => {
const deltaYStep = 100; //equivalent to 1x zoom 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 // zoom in
await page.mouse.wheel(0, deltaYStep * 2); 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 zoomedBoundingBox = await page.locator(backgroundImageSelector).boundingBox();
const imageCenterX = zoomedBoundingBox.x + zoomedBoundingBox.width / 2; const imageCenterX = zoomedBoundingBox.x + zoomedBoundingBox.width / 2;
const imageCenterY = zoomedBoundingBox.y + zoomedBoundingBox.height / 2; const imageCenterY = zoomedBoundingBox.y + zoomedBoundingBox.height / 2;
@ -131,6 +132,36 @@ test.describe('Example Imagery Object', () => {
expect(afterDownPanBoundingBox.y).toBeLessThan(afterUpPanBoundingBox.y); 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 }) => { test('Can use + - buttons to zoom on the image @unstable', async ({ page }) => {
await buttonZoomOnImageAndAssert(page); await buttonZoomOnImageAndAssert(page);
}); });
@ -185,24 +216,7 @@ test.describe('Example Imagery in Display Layout', () => {
displayLayout = await createDomainObjectWithDefaults(page, { type: 'Display Layout' }); displayLayout = await createDomainObjectWithDefaults(page, { type: 'Display Layout' });
await page.goto(displayLayout.url); await page.goto(displayLayout.url);
/* Create Sine Wave Generator with minimum Image Load Delay */ await 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')
]);
await expect(page.locator('.l-browse-bar__object-name')).toContainText( await expect(page.locator('.l-browse-bar__object-name')).toContainText(
'Unnamed Example Imagery' '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').click();
await page.locator('div[title="Resize object height"] > input').fill('100'); 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/); 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', () => { test.describe('Example Imagery in Flexible layout', () => {
@ -692,7 +744,6 @@ async function panZoomAndAssertImageProperties(page) {
async function mouseZoomOnImageAndAssert(page, factor = 2) { async function mouseZoomOnImageAndAssert(page, factor = 2) {
// Zoom in // Zoom in
const originalImageDimensions = await page.locator(backgroundImageSelector).boundingBox(); const originalImageDimensions = await page.locator(backgroundImageSelector).boundingBox();
await page.locator(backgroundImageSelector).hover({ trial: true });
const deltaYStep = 100; // equivalent to 1x zoom const deltaYStep = 100; // equivalent to 1x zoom
await page.mouse.wheel(0, deltaYStep * factor); await page.mouse.wheel(0, deltaYStep * factor);
const zoomedBoundingBox = await page.locator(backgroundImageSelector).boundingBox(); const zoomedBoundingBox = await page.locator(backgroundImageSelector).boundingBox();
@ -703,7 +754,7 @@ async function mouseZoomOnImageAndAssert(page, factor = 2) {
await page.mouse.move(imageCenterX, imageCenterY); await page.mouse.move(imageCenterX, imageCenterY);
// Wait for zoom animation to finish // 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(); const imageMouseZoomed = await page.locator(backgroundImageSelector).boundingBox();
if (factor > 0) { if (factor > 0) {
@ -819,3 +870,26 @@ async function resetImageryPanAndZoom(page) {
await panZoomResetBtn.click(); await panZoomResetBtn.click();
await waitForAnimations(backgroundImage); 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,39 +1,40 @@
{ {
"name": "openmct", "name": "openmct",
"version": "2.2.4-SNAPSHOT", "version": "2.2.6-SNAPSHOT",
"description": "The Open MCT core platform", "description": "The Open MCT core platform",
"devDependencies": { "devDependencies": {
"@babel/eslint-parser": "7.19.1", "@babel/eslint-parser": "7.22.5",
"@braintree/sanitize-url": "6.0.2", "@braintree/sanitize-url": "6.0.2",
"@deploysentinel/playwright": "0.3.4", "@deploysentinel/playwright": "0.3.4",
"@percy/cli": "1.24.0", "@percy/cli": "1.26.0",
"@percy/playwright": "1.0.4", "@percy/playwright": "1.0.4",
"@playwright/test": "1.32.3", "@playwright/test": "1.32.3",
"@types/eventemitter3": "1.2.0", "@types/eventemitter3": "1.2.0",
"@types/jasmine": "4.3.1", "@types/jasmine": "4.3.4",
"@types/lodash": "4.14.192", "@types/lodash": "4.14.192",
"babel-loader": "9.1.0", "babel-loader": "9.1.0",
"babel-plugin-istanbul": "6.1.1", "babel-plugin-istanbul": "6.1.1",
"codecov": "3.8.3", "codecov": "3.8.3",
"comma-separated-values": "3.6.4", "comma-separated-values": "3.6.4",
"copy-webpack-plugin": "11.0.0", "copy-webpack-plugin": "11.0.0",
"css-loader": "6.7.3", "css-loader": "6.8.1",
"d3-axis": "3.0.0", "d3-axis": "3.0.0",
"d3-scale": "3.3.0", "d3-scale": "3.3.0",
"d3-selection": "3.0.0", "d3-selection": "3.0.0",
"eslint": "8.40.0", "eslint": "8.43.0",
"eslint-plugin-compat": "4.1.4", "eslint-plugin-compat": "4.1.4",
"eslint-config-prettier": "8.8.0", "eslint-config-prettier": "8.8.0",
"eslint-plugin-playwright": "0.12.0", "eslint-plugin-playwright": "0.12.0",
"eslint-plugin-prettier": "4.2.1", "eslint-plugin-prettier": "4.2.1",
"eslint-plugin-vue": "9.13.0", "eslint-plugin-vue": "9.15.0",
"eslint-plugin-you-dont-need-lodash-underscore": "6.12.0", "eslint-plugin-you-dont-need-lodash-underscore": "6.12.0",
"eventemitter3": "1.2.0", "eventemitter3": "1.2.0",
"file-saver": "2.0.5", "file-saver": "2.0.5",
"flatbush": "4.1.0",
"git-rev-sync": "3.0.2", "git-rev-sync": "3.0.2",
"html2canvas": "1.4.1", "html2canvas": "1.4.1",
"imports-loader": "4.0.1", "imports-loader": "4.0.1",
"jasmine-core": "4.5.0", "jasmine-core": "5.0.0",
"karma": "6.4.2", "karma": "6.4.2",
"karma-chrome-launcher": "3.2.0", "karma-chrome-launcher": "3.2.0",
"karma-cli": "2.0.0", "karma-cli": "2.0.0",
@ -44,10 +45,9 @@
"karma-sourcemap-loader": "0.4.0", "karma-sourcemap-loader": "0.4.0",
"karma-spec-reporter": "0.0.36", "karma-spec-reporter": "0.0.36",
"karma-webpack": "5.0.0", "karma-webpack": "5.0.0",
"kdbush": "3.0.0",
"location-bar": "3.0.1", "location-bar": "3.0.1",
"lodash": "4.17.21", "lodash": "4.17.21",
"mini-css-extract-plugin": "2.7.5", "mini-css-extract-plugin": "2.7.6",
"moment": "2.29.4", "moment": "2.29.4",
"moment-duration-format": "2.3.2", "moment-duration-format": "2.3.2",
"moment-timezone": "0.5.41", "moment-timezone": "0.5.41",
@ -60,20 +60,20 @@
"printj": "1.3.1", "printj": "1.3.1",
"resolve-url-loader": "5.0.0", "resolve-url-loader": "5.0.0",
"sanitize-html": "2.10.0", "sanitize-html": "2.10.0",
"sass": "1.62.1", "sass": "1.63.4",
"sass-loader": "13.2.2", "sass-loader": "13.3.2",
"sinon": "15.0.1", "sinon": "15.1.0",
"style-loader": "3.3.2", "style-loader": "3.3.3",
"typescript": "5.0.4", "typescript": "5.1.3",
"uuid": "9.0.0", "uuid": "9.0.0",
"vue": "2.6.14", "vue": "2.6.14",
"vue-eslint-parser": "9.2.1", "vue-eslint-parser": "9.3.1",
"vue-loader": "15.9.8", "vue-loader": "15.9.8",
"vue-template-compiler": "2.6.14", "vue-template-compiler": "2.6.14",
"webpack": "5.81.0", "webpack": "5.86.0",
"webpack-cli": "5.0.2", "webpack-cli": "5.1.1",
"webpack-dev-server": "4.13.3", "webpack-dev-server": "4.15.1",
"webpack-merge": "5.8.0" "webpack-merge": "5.9.0"
}, },
"scripts": { "scripts": {
"clean": "rm -rf ./dist ./node_modules ./package-lock.json ./coverage ./html-test-results ./test-results ./.nyc_output ", "clean": "rm -rf ./dist ./node_modules ./package-lock.json ./coverage ./html-test-results ./test-results ./.nyc_output ",
@ -111,7 +111,7 @@
"url": "https://github.com/nasa/openmct.git" "url": "https://github.com/nasa/openmct.git"
}, },
"engines": { "engines": {
"node": ">=16.19.1" "node": ">=16.19.1 <20"
}, },
"browserslist": [ "browserslist": [
"Firefox ESR", "Firefox ESR",

View File

@ -76,6 +76,9 @@ const ANNOTATION_LAST_CREATED = 'annotationLastCreated';
* @constructor * @constructor
*/ */
export default class AnnotationAPI extends EventEmitter { export default class AnnotationAPI extends EventEmitter {
/** @type {Map<ANNOTATION_TYPES, Array<(a, b) => boolean >>} */
#targetComparatorMap;
/** /**
* @param {OpenMCT} openmct * @param {OpenMCT} openmct
*/ */
@ -84,6 +87,7 @@ export default class AnnotationAPI extends EventEmitter {
this.openmct = openmct; this.openmct = openmct;
this.availableTags = {}; this.availableTags = {};
this.namespaceToSaveAnnotations = ''; this.namespaceToSaveAnnotations = '';
this.#targetComparatorMap = new Map();
this.ANNOTATION_TYPES = ANNOTATION_TYPES; this.ANNOTATION_TYPES = ANNOTATION_TYPES;
this.ANNOTATION_TYPE = ANNOTATION_TYPE; this.ANNOTATION_TYPE = ANNOTATION_TYPE;
@ -246,15 +250,16 @@ export default class AnnotationAPI extends EventEmitter {
/** /**
* @method getAnnotations * @method getAnnotations
* @param {Identifier} domainObjectIdentifier - The domain object identifier to use to search for annotations. For example, a telemetry object identifier. * @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 * @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 keyStringQuery = this.openmct.objects.makeKeyString(domainObjectIdentifier);
const searchResults = ( const searchResults = (
await Promise.all( await Promise.all(
this.openmct.objects.search( this.openmct.objects.search(
keyStringQuery, keyStringQuery,
null, abortSignal,
this.openmct.objects.SEARCH_TYPES.ANNOTATIONS this.openmct.objects.SEARCH_TYPES.ANNOTATIONS
) )
) )
@ -384,7 +389,8 @@ export default class AnnotationAPI extends EventEmitter {
const combinedResults = []; const combinedResults = [];
results.forEach((currentAnnotation) => { results.forEach((currentAnnotation) => {
const existingAnnotation = combinedResults.find((annotationToFind) => { const existingAnnotation = combinedResults.find((annotationToFind) => {
return _.isEqual(currentAnnotation.targets, annotationToFind.targets); const { annotationType, targets } = currentAnnotation;
return this.areAnnotationTargetsEqual(annotationType, targets, annotationToFind.targets);
}); });
if (!existingAnnotation) { if (!existingAnnotation) {
combinedResults.push(currentAnnotation); combinedResults.push(currentAnnotation);
@ -460,4 +466,35 @@ export default class AnnotationAPI extends EventEmitter {
return breakApartSeparateTargets; 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,87 @@ describe('The Annotation API', () => {
expect(results.length).toEqual(0); 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('can create a tag', async () => {
const annotationObject = await openmct.annotation.create(tagCreationArguments);
expect(annotationObject).toBeDefined();
expect(annotationObject.type).toEqual('annotation');
expect(annotationObject.tags).toContain('aWonderfulTag');
});
it('can delete a tag', async () => {
const annotationObject = await openmct.annotation.create(tagCreationArguments);
expect(annotationObject).toBeDefined();
openmct.annotation.deleteAnnotations([annotationObject]);
expect(annotationObject._deleted).toBeTrue();
});
it('can remove all tags', async () => {
const annotationObject = await openmct.annotation.create(tagCreationArguments);
expect(annotationObject).toBeDefined();
expect(() => {
openmct.annotation.deleteAnnotations([annotationObject]);
}).not.toThrow();
expect(annotationObject._deleted).toBeTrue();
});
it('can add/delete/add a tag', async () => {
let annotationObject = await openmct.annotation.create(tagCreationArguments);
expect(annotationObject).toBeDefined();
expect(annotationObject.type).toEqual('annotation');
expect(annotationObject.tags).toContain('aWonderfulTag');
openmct.annotation.deleteAnnotations([annotationObject]);
expect(annotationObject._deleted).toBeTrue();
annotationObject = await openmct.annotation.create(tagCreationArguments);
expect(annotationObject).toBeDefined();
expect(annotationObject.type).toEqual('annotation');
expect(annotationObject.tags).toContain('aWonderfulTag');
expect(annotationObject._deleted).toBeFalse();
});
});
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 DefaultMetadataProvider from './DefaultMetadataProvider';
import objectUtils from 'objectUtils'; 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 { export default class TelemetryAPI {
#isGreedyLAD; #isGreedyLAD;
@ -169,25 +199,35 @@ export default class TelemetryAPI {
} }
/** /**
* @private * @param {TelemetryRequestOptions} options options for the telemetry request
* Though used in TelemetryCollection as well * @returns {TelemetryRequestOptions} the options, with defaults filled in
*/ */
standardizeRequestOptions(options) { standardizeRequestOptions(options = {}) {
if (!Object.prototype.hasOwnProperty.call(options, 'start')) { if (!Object.hasOwn(options, 'start')) {
if (options.timeContext?.bounds()) {
options.start = options.timeContext.bounds().start;
} else {
options.start = this.openmct.time.bounds().start; options.start = this.openmct.time.bounds().start;
} }
if (!Object.prototype.hasOwnProperty.call(options, 'end')) {
options.end = this.openmct.time.bounds().end;
} }
if (!Object.prototype.hasOwnProperty.call(options, 'domain')) { 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.hasOwn(options, 'domain')) {
options.domain = this.openmct.time.timeSystem().key; options.domain = this.openmct.time.timeSystem().key;
} }
if (!Object.prototype.hasOwnProperty.call(options, 'timeContext')) { if (!Object.hasOwn(options, 'timeContext')) {
options.timeContext = this.openmct.time; options.timeContext = this.openmct.time;
} }
return options;
} }
/** /**
@ -265,7 +305,7 @@ export default class TelemetryAPI {
* @memberof module:openmct.TelemetryAPI~TelemetryProvider# * @memberof module:openmct.TelemetryAPI~TelemetryProvider#
* @param {module:openmct.DomainObject} domainObject the object * @param {module:openmct.DomainObject} domainObject the object
* which has associated telemetry * which has associated telemetry
* @param {module:openmct.TelemetryAPI~TelemetryRequest} options * @param {TelemetryRequestOptions} options
* options for this telemetry collection request * options for this telemetry collection request
* @returns {TelemetryCollection} a TelemetryCollection instance * @returns {TelemetryCollection} a TelemetryCollection instance
*/ */
@ -283,7 +323,7 @@ export default class TelemetryAPI {
* @memberof module:openmct.TelemetryAPI~TelemetryProvider# * @memberof module:openmct.TelemetryAPI~TelemetryProvider#
* @param {module:openmct.DomainObject} domainObject the object * @param {module:openmct.DomainObject} domainObject the object
* which has associated telemetry * which has associated telemetry
* @param {module:openmct.TelemetryAPI~TelemetryRequest} options * @param {TelemetryRequestOptions} options
* options for this historical request * options for this historical request
* @returns {Promise.<object[]>} a promise for an array of * @returns {Promise.<object[]>} a promise for an array of
* telemetry data * telemetry data
@ -339,6 +379,7 @@ export default class TelemetryAPI {
* @memberof module:openmct.TelemetryAPI~TelemetryProvider# * @memberof module:openmct.TelemetryAPI~TelemetryProvider#
* @param {module:openmct.DomainObject} domainObject the object * @param {module:openmct.DomainObject} domainObject the object
* which has associated telemetry * which has associated telemetry
* @param {TelemetryRequestOptions} options configuration items for subscription
* @param {Function} callback the callback to invoke with new data, as * @param {Function} callback the callback to invoke with new data, as
* it becomes available * it becomes available
* @returns {Function} a function which may be called to terminate * @returns {Function} a function which may be called to terminate

View File

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

View File

@ -135,7 +135,6 @@ class UserAPI extends EventEmitter {
if (!this.hasProvider()) { if (!this.hasProvider()) {
return null; return null;
} }
const activeRole = this.getActiveRole(); const activeRole = this.getActiveRole();
return this._provider.canProvideStatusForRole?.(activeRole); return this._provider.canProvideStatusForRole?.(activeRole);

View File

@ -49,10 +49,16 @@ define(['./components/FiltersView.vue', 'vue'], function (FiltersView, Vue) {
}); });
}, },
showTab: function (isEditing) { showTab: function (isEditing) {
const hasPersistedFilters = Boolean(domainObject?.configuration?.filters); if (isEditing) {
const hasGlobalFilters = Boolean(domainObject?.configuration?.globalFilters); return true;
}
return hasPersistedFilters || hasGlobalFilters; const metadata = openmct.telemetry.getMetadata(domainObject);
const metadataWithFilters = metadata
? metadata.valueMetadatas.filter((value) => value.filters)
: [];
return metadataWithFilters.length;
}, },
priority: function () { priority: function () {
return openmct.priority.DEFAULT; return openmct.priority.DEFAULT;

View File

@ -0,0 +1,53 @@
# Server side filtering in Open MCT
## Introduction
In Open MCT, filters can be constructed to filter out telemetry data on the server side. This is useful for reducing the amount of data that needs to be sent to the client. For example, in [Open MCT for MCWS](https://github.com/NASA-AMMOS/openmct-mcws/blob/e8846d325cc3f659d8ad58d1d24efaafbe2b6bb7/src/constants.js#L115), they can be used to filter realtime data from recorded data. In the [Open MCT YAMCS plugin](https://github.com/akhenry/openmct-yamcs/blob/9c4ed03e23848db3215fdb6a988ba34b445a3989/src/providers/events.js#L44), we can use them to filter incoming event data by severity.
## Installing the filter plugin
You'll need to install the filter plugin first. For example:
```js
openmct.install(openmct.plugins.Filters(['telemetry.plot.overlay', 'table']));
```
will install the filters plugin and have it apply to overlay plots and tables. You can see an example of this in the [Open MCT YAMCS plugin](https://github.com/akhenry/openmct-yamcs/blob/9c4ed03e23848db3215fdb6a988ba34b445a3989/example/index.js#L58).
## Defining a filter
To define a filter, you'll need to add a new `filter` property to the domain object's `telemetry` metadata underneath the `values` array. For example, if you have a domain object with a `telemetry` metadata that looks like this:
```js
{
key: 'fruit',
name: 'Types of fruit',
filters: [{
singleSelectionThreshold: true,
comparator: 'equals',
possibleValues: [
{ name: 'Apple', value: 'apple' },
{ name: 'Banana', value: 'banana' },
{ name: 'Orange', value: 'orange' }
]
}]
}
```
This will define a filter that allows an operator to choose one (due to `singleSelectionThreshold` being `true`) of the three possible values. The `comparator` property defines how the filter will be applied to the telemetry data.
Setting `singleSelectionThreshold` to `false` will render the `possibleValues` as a series of checkboxes. Removing the `possibleValues` property will render the filter as a text box, allowing the operator to enter a value to filter on.
Note that how the filter is interpreted is ultimately decided by the individual telemetry providers.
## Implementing a filter in a telemetry provider
Implementing a filter requires two parts:
- First, one needs to add the filter implementation to the [subscribe](https://github.com/nasa/openmct/blob/5df7971438acb9e8b933edda2aed432b1b8bb27d/src/api/telemetry/TelemetryAPI.js#L366) method in your telemetry provider. The filter will be passed to you in the `options` argument. You can either add the filter to your telemetry subscription request, or filter manually as new messages appears. An example of the latter is [shown in the YAMCS plugin for Open MCT](https://github.com/akhenry/openmct-yamcs/blob/9c4ed03e23848db3215fdb6a988ba34b445a3989/src/providers/events.js#L95).
- Second, one needs to add the filter implementation to the [request](https://github.com/nasa/openmct/blob/5df7971438acb9e8b933edda2aed432b1b8bb27d/src/api/telemetry/TelemetryAPI.js#L318) method in your telemetry provider. The filter again will be passed to you in the `options` argument. You can either add the filter to your telemetry request, or filter manually after the request is made. An example of the former is [shown in the YAMCS plugin for Open MCT](https://github.com/akhenry/openmct-yamcs/blob/9c4ed03e23848db3215fdb6a988ba34b445a3989/src/providers/historical-telemetry-provider.js#L171).
## Using filters
If you installed the plugin to have it apply to `table`, create a Telemetry Table in Open MCT and drag your telemetry object that contains the filter to it. Then click "Edit", and notice the "Filter" tab in the inspector. It allows operator to either select a "Global Filter", or a regular filter. The "Global Filter" will apply for all telemetry objects in the table, while the regular filter will only apply to the telemetry object that it is defined on.

View File

@ -37,14 +37,37 @@
:id="`${filter}filterControl`" :id="`${filter}filterControl`"
class="c-input--flex" class="c-input--flex"
type="text" type="text"
:aria-label="label"
:disabled="useGlobal" :disabled="useGlobal"
:value="persistedValue(filter)" :value="persistedValue(filter)"
@change="updateFilterValue($event, filter)" @change="updateFilterValueFromString($event, filter)"
/> />
</template> </template>
<!-- Dropdown, editing -->
<template v-if="filter.possibleValues && filter.singleSelectionThreshold && isEditing">
<select
name="setSelectionThreshold"
:aria-label="label"
:disabled="useGlobal"
@change="updateFilterValueFromDropdown($event, filter.comparator, $event.target.value)"
>
<option key="NONE" value="NONE" selected="isSelected(filter.comparator, option.value)">
None
</option>
<option
v-for="option in filter.possibleValues"
:key="option.label"
:value="option.value"
:selected="isSelected(filter.comparator, option.value)"
>
{{ option.label }}
</option>
</select>
</template>
<!-- Checkbox list, editing --> <!-- Checkbox list, editing -->
<template v-if="filter.possibleValues && isEditing"> <template v-if="filter.possibleValues && isEditing && !filter.singleSelectionThreshold">
<div <div
v-for="option in filter.possibleValues" v-for="option in filter.possibleValues"
:key="option.value" :key="option.value"
@ -54,9 +77,10 @@
:id="`${option.value}filterControl`" :id="`${option.value}filterControl`"
class="c-checkbox-list__input" class="c-checkbox-list__input"
type="checkbox" type="checkbox"
:aria-label="label"
:disabled="useGlobal" :disabled="useGlobal"
:checked="isChecked(filter.comparator, option.value)" :checked="isSelected(filter.comparator, option.value)"
@change="updateFilterValue($event, filter.comparator, option.value)" @change="updateFilterValueFromCheckbox($event, filter.comparator, option.value)"
/> />
<span class="c-checkbox-list__value"> <span class="c-checkbox-list__value">
{{ option.label }} {{ option.label }}
@ -89,6 +113,10 @@ export default {
type: Object, type: Object,
required: true required: true
}, },
label: {
type: String,
required: true
},
useGlobal: Boolean, useGlobal: Boolean,
persistedFilters: { persistedFilters: {
type: Object, type: Object,
@ -112,7 +140,7 @@ export default {
toggleIsEditing(isEditing) { toggleIsEditing(isEditing) {
this.isEditing = isEditing; this.isEditing = isEditing;
}, },
isChecked(comparator, value) { isSelected(comparator, value) {
if (this.persistedFilters[comparator] && this.persistedFilters[comparator].includes(value)) { if (this.persistedFilters[comparator] && this.persistedFilters[comparator].includes(value)) {
return true; return true;
} else { } else {
@ -122,11 +150,17 @@ export default {
persistedValue(comparator) { persistedValue(comparator) {
return this.persistedFilters && this.persistedFilters[comparator]; return this.persistedFilters && this.persistedFilters[comparator];
}, },
updateFilterValue(event, comparator, value) { updateFilterValueFromString(event, comparator) {
if (value !== undefined) {
this.$emit('filterSelected', this.filterField.key, comparator, value, event.target.checked);
} else {
this.$emit('filterTextValueChanged', this.filterField.key, comparator, event.target.value); this.$emit('filterTextValueChanged', this.filterField.key, comparator, event.target.value);
},
updateFilterValueFromCheckbox(event, comparator, value) {
this.$emit('filterSelected', this.filterField.key, comparator, value, event.target.checked);
},
updateFilterValueFromDropdown(event, comparator, value) {
if (value === 'NONE') {
this.$emit('clearFilters', this.filterField.key);
} else {
this.$emit('filterSingleSelected', this.filterField.key, comparator, value);
} }
}, },
getFilterLabels(filter) { getFilterLabels(filter) {

View File

@ -63,8 +63,11 @@
:filter-field="metadatum" :filter-field="metadatum"
:use-global="persistedFilters.useGlobal" :use-global="persistedFilters.useGlobal"
:persisted-filters="updatedFilters[metadatum.key]" :persisted-filters="updatedFilters[metadatum.key]"
@filterSelected="updateFiltersWithSelectedValue" label="Specific Filter"
@filterSelected="updateMultipleFiltersWithSelectedValue"
@filterTextValueChanged="updateFiltersWithTextValue" @filterTextValueChanged="updateFiltersWithTextValue"
@filterSingleSelected="updateSingleSelection"
@clearFilters="clearFilters"
/> />
</ul> </ul>
</div> </div>
@ -140,7 +143,7 @@ export default {
toggleExpanded() { toggleExpanded() {
this.expanded = !this.expanded; this.expanded = !this.expanded;
}, },
updateFiltersWithSelectedValue(key, comparator, valueName, value) { updateMultipleFiltersWithSelectedValue(key, comparator, valueName, value) {
let filterValue = this.updatedFilters[key]; let filterValue = this.updatedFilters[key];
if (filterValue[comparator]) { if (filterValue[comparator]) {
@ -159,6 +162,10 @@ export default {
this.$emit('updateFilters', this.keyString, this.updatedFilters); this.$emit('updateFilters', this.keyString, this.updatedFilters);
}, },
clearFilters(key) {
this.$set(this.updatedFilters, key, {});
this.$emit('updateFilters', this.keyString, this.updatedFilters);
},
updateFiltersWithTextValue(key, comparator, value) { updateFiltersWithTextValue(key, comparator, value) {
if (value.trim() === '') { if (value.trim() === '') {
this.$set(this.updatedFilters, key, {}); this.$set(this.updatedFilters, key, {});
@ -168,6 +175,10 @@ export default {
this.$emit('updateFilters', this.keyString, this.updatedFilters); this.$emit('updateFilters', this.keyString, this.updatedFilters);
}, },
updateSingleSelection(key, comparator, value) {
this.$set(this.updatedFilters[key], comparator, [value]);
this.$emit('updateFilters', this.keyString, this.updatedFilters);
},
useGlobalFilter(checked) { useGlobalFilter(checked) {
this.updatedFilters.useGlobal = checked; this.updatedFilters.useGlobal = checked;
this.$emit('updateFilters', this.keyString, this.updatedFilters, checked); this.$emit('updateFilters', this.keyString, this.updatedFilters, checked);

View File

@ -43,8 +43,11 @@
:key="metadatum.key" :key="metadatum.key"
:filter-field="metadatum" :filter-field="metadatum"
:persisted-filters="updatedFilters[metadatum.key]" :persisted-filters="updatedFilters[metadatum.key]"
label="Global Filter"
@filterSelected="updateFiltersWithSelectedValue" @filterSelected="updateFiltersWithSelectedValue"
@filterTextValueChanged="updateFiltersWithTextValue" @filterTextValueChanged="updateFiltersWithTextValue"
@filterSingleSelected="updateSingleSelection"
@clearFilters="clearFilters"
/> />
</ul> </ul>
</li> </li>
@ -97,6 +100,10 @@ export default {
toggleExpanded() { toggleExpanded() {
this.expanded = !this.expanded; this.expanded = !this.expanded;
}, },
clearFilters(key) {
this.$set(this.updatedFilters, key, {});
this.$emit('persistGlobalFilters', key, this.updatedFilters);
},
updateFiltersWithSelectedValue(key, comparator, valueName, value) { updateFiltersWithSelectedValue(key, comparator, valueName, value) {
let filterValue = this.updatedFilters[key]; let filterValue = this.updatedFilters[key];
@ -116,6 +123,10 @@ export default {
this.$emit('persistGlobalFilters', key, this.updatedFilters); this.$emit('persistGlobalFilters', key, this.updatedFilters);
}, },
updateSingleSelection(key, comparator, value) {
this.$set(this.updatedFilters[key], comparator, [value]);
this.$emit('persistGlobalFilters', key, this.updatedFilters);
},
updateFiltersWithTextValue(key, comparator, value) { updateFiltersWithTextValue(key, comparator, value) {
if (value.trim() === '') { if (value.trim() === '') {
this.$set(this.updatedFilters, key, {}); this.$set(this.updatedFilters, key, {});

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; position: absolute;
left: 0; left: 0;
top: 0; top: 0;
z-index: 2; z-index: 3;
@include userSelectNone; @include userSelectNone;
} }

View File

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

View File

@ -38,6 +38,11 @@
fetchpriority="low" fetchpriority="low"
@load="imageLoadCompleted" @load="imageLoadCompleted"
/> />
<i
v-show="showAnnotationIndicator"
class="c-thumb__annotation-indicator icon-status-poll-edit"
>
</i>
</a> </a>
<div v-if="viewableArea" class="c-thumb__viewable-area" :style="viewableAreaStyle"></div> <div v-if="viewableArea" class="c-thumb__viewable-area" :style="viewableAreaStyle"></div>
<div class="c-thumb__timestamp">{{ image.formattedTime }}</div> <div class="c-thumb__timestamp">{{ image.formattedTime }}</div>
@ -66,6 +71,12 @@ export default {
type: Boolean, type: Boolean,
required: true required: true
}, },
imageryAnnotations: {
type: Array,
default() {
return [];
}
},
viewableArea: { viewableArea: {
type: Object, type: Object,
default: function () { default: function () {
@ -125,6 +136,11 @@ export default {
width: `${width}px`, width: `${width}px`,
height: `${height}px` height: `${height}px`
}; };
},
showAnnotationIndicator() {
return this.imageryAnnotations.some((annotation) => {
return !annotation._deleted;
});
} }
}, },
methods: { methods: {

View File

@ -88,6 +88,13 @@
:image="focusedImage" :image="focusedImage"
:sized-image-dimensions="sizedImageDimensions" :sized-image-dimensions="sizedImageDimensions"
/> />
<AnnotationsCanvas
v-if="shouldDisplayAnnotations"
:image="focusedImage"
:imagery-annotations="imageryAnnotations[focusedImage.time]"
@annotationMarqueed="handlePauseButton(true)"
@annotationsChanged="loadAnnotations"
/>
</div> </div>
</div> </div>
@ -173,6 +180,7 @@
:key="`${image.thumbnailUrl || image.url}-${image.time}-${index}`" :key="`${image.thumbnailUrl || image.url}-${image.time}-${index}`"
:image="image" :image="image"
:active="focusedImageIndex === index" :active="focusedImageIndex === index"
:imagery-annotations="imageryAnnotations[image.time]"
:selected="focusedImageIndex === index && isPaused" :selected="focusedImageIndex === index && isPaused"
:real-time="!isFixed" :real-time="!isFixed"
:viewable-area="focusedImageIndex === index ? viewableArea : null" :viewable-area="focusedImageIndex === index ? viewableArea : null"
@ -200,6 +208,7 @@ import Compass from './Compass/Compass.vue';
import ImageControls from './ImageControls.vue'; import ImageControls from './ImageControls.vue';
import ImageThumbnail from './ImageThumbnail.vue'; import ImageThumbnail from './ImageThumbnail.vue';
import imageryData from '../../imagery/mixins/imageryData'; import imageryData from '../../imagery/mixins/imageryData';
import AnnotationsCanvas from './AnnotationsCanvas.vue';
const REFRESH_CSS_MS = 500; const REFRESH_CSS_MS = 500;
const DURATION_TRACK_MS = 1000; const DURATION_TRACK_MS = 1000;
@ -232,7 +241,8 @@ export default {
components: { components: {
Compass, Compass,
ImageControls, ImageControls,
ImageThumbnail ImageThumbnail,
AnnotationsCanvas
}, },
mixins: [imageryData], mixins: [imageryData],
inject: ['openmct', 'domainObject', 'objectPath', 'currentView', 'imageFreshnessOptions'], inject: ['openmct', 'domainObject', 'objectPath', 'currentView', 'imageFreshnessOptions'],
@ -295,7 +305,8 @@ export default {
animateZoom: true, animateZoom: true,
imagePanned: false, imagePanned: false,
forceShowThumbnails: false, forceShowThumbnails: false,
animateThumbScroll: false animateThumbScroll: false,
imageryAnnotations: {}
}; };
}, },
computed: { computed: {
@ -425,6 +436,19 @@ export default {
return result; 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() { shouldDisplayCompass() {
const imageHeightAndWidth = this.sizedImageHeight !== 0 && this.sizedImageWidth !== 0; const imageHeightAndWidth = this.sizedImageHeight !== 0 && this.sizedImageWidth !== 0;
const display = const display =
@ -631,6 +655,9 @@ export default {
} }
} }
}, },
created() {
this.abortController = new AbortController();
},
async mounted() { async mounted() {
eventHelpers.extend(this); eventHelpers.extend(this);
this.focusedImageWrapper = this.$refs.focusedImageWrapper; this.focusedImageWrapper = this.$refs.focusedImageWrapper;
@ -689,8 +716,12 @@ export default {
this.listenTo(this.focusedImageWrapper, 'wheel', this.wheelZoom, this); this.listenTo(this.focusedImageWrapper, 'wheel', this.wheelZoom, this);
this.loadVisibleLayers(); this.loadVisibleLayers();
this.loadAnnotations();
this.openmct.selection.on('change', this.updateSelection);
}, },
beforeDestroy() { beforeDestroy() {
this.abortController.abort();
this.persistVisibleLayers(); this.persistVisibleLayers();
this.stopFollowingTimeContext(); this.stopFollowingTimeContext();
@ -716,6 +747,15 @@ export default {
} }
this.stopListening(this.focusedImageWrapper, 'wheel', this.wheelZoom, this); 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: { methods: {
calculateViewHeight() { calculateViewHeight() {
@ -743,6 +783,15 @@ export default {
this.timeContext.off('clock', this.trackDuration); 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() { expand() {
// check for modifier keys so it doesnt interfere with the layout // check for modifier keys so it doesnt interfere with the layout
if (this.cursorStates.modifierKeyPressed) { 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() { persistVisibleLayers() {
if ( if (
this.domainObject.configuration && this.domainObject.configuration &&
@ -979,7 +1063,9 @@ export default {
} }
await Vue.nextTick(); await Vue.nextTick();
if (this.$refs.thumbsWrapper) {
this.$refs.thumbsWrapper.scrollLeft = scrollWidth; this.$refs.thumbsWrapper.scrollLeft = scrollWidth;
}
}, },
scrollHandler() { scrollHandler() {
if (this.isPaused) { 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"> <div class="c-checkbox-list js-checkbox-menu c-menu--to-left c-menu--has-close-btn">
<ul @click="$event.stopPropagation()"> <ul @click="$event.stopPropagation()">
<li v-for="(layer, index) in layers" :key="index"> <li v-for="(layer, index) in layers" :key="index">
<label>
<input <input
v-if="layer.visible" :checked="layer.visible"
:id="index + 'LayerControl'"
checked
type="checkbox" type="checkbox"
@change="toggleLayerVisibility(index)" @change="toggleLayerVisibility(index)"
/> />
<input {{ layer.name }}
v-else </label>
:id="index + 'LayerControl'"
type="checkbox"
@change="toggleLayerVisibility(index)"
/>
<label :for="index + 'LayerControl'">{{ layer.name }}</label>
</li> </li>
</ul> </ul>
</div> </div>

View File

@ -293,6 +293,13 @@
width: 100%; width: 100%;
} }
&__annotation-indicator {
color: $colorClickIconButton;
position: absolute;
top: 6px;
right: 8px;
}
&__timestamp { &__timestamp {
flex: 0 0 auto; flex: 0 0 auto;
padding: 2px 3px; padding: 2px 3px;
@ -540,3 +547,11 @@
align-self: flex-end; 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.timeKey = this.timeSystem.key;
this.timeFormatter = this.getFormatter(this.timeKey); 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('add', this.dataAdded);
this.telemetryCollection.on('remove', this.dataRemoved); this.telemetryCollection.on('remove', this.dataRemoved);
this.telemetryCollection.on('clear', this.dataCleared); this.telemetryCollection.on('clear', this.dataCleared);

View File

@ -41,7 +41,6 @@
<script> <script>
import TagEditor from './tags/TagEditor.vue'; import TagEditor from './tags/TagEditor.vue';
import _ from 'lodash';
export default { export default {
components: { components: {
@ -123,11 +122,13 @@ export default {
} }
}, },
async mounted() { async mounted() {
this.abortController = null;
this.openmct.annotation.on('targetDomainObjectAnnotated', this.loadAnnotationForTargetObject); this.openmct.annotation.on('targetDomainObjectAnnotated', this.loadAnnotationForTargetObject);
this.openmct.selection.on('change', this.updateSelection); this.openmct.selection.on('change', this.updateSelection);
await this.updateSelection(this.openmct.selection.get()); await this.updateSelection(this.openmct.selection.get());
}, },
beforeDestroy() { beforeDestroy() {
this.openmct.annotation.off('targetDomainObjectAnnotated', this.loadAnnotationForTargetObject);
this.openmct.selection.off('change', this.updateSelection); this.openmct.selection.off('change', this.updateSelection);
const unobserveEntryFunctions = Object.values(this.unobserveEntries); const unobserveEntryFunctions = Object.values(this.unobserveEntries);
unobserveEntryFunctions.forEach((unobserveEntry) => { unobserveEntryFunctions.forEach((unobserveEntry) => {
@ -189,20 +190,34 @@ export default {
} }
}, },
async loadAnnotationForTargetObject(target) { async loadAnnotationForTargetObject(target) {
const targetID = this.openmct.objects.makeKeyString(target.identifier); // If the user changes targets while annotations are loading,
const allAnnotationsForTarget = await this.openmct.annotation.getAnnotations( // abort the previous request.
target.identifier if (this.abortController !== null) {
); this.abortController.abort();
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];
return _.isEqual(fetchedTargetDetails, selectedTargetDetails); 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); 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.getSearchResults = debounce(this.getSearchResults, 500);
this.syncUrlWithPageAndSection = debounce(this.syncUrlWithPageAndSection, 100); this.syncUrlWithPageAndSection = debounce(this.syncUrlWithPageAndSection, 100);
}, },
async mounted() { async created() {
this.transaction = null;
this.abortController = new AbortController();
try {
await this.loadAnnotations(); await this.loadAnnotations();
} catch (err) {
if (err.name !== 'AbortError') {
throw err;
}
}
},
mounted() {
this.formatSidebar(); this.formatSidebar();
this.setSectionAndPageFromUrl(); this.setSectionAndPageFromUrl();
this.openmct.selection.on('change', this.updateSelection); this.openmct.selection.on('change', this.updateSelection);
this.transaction = null;
window.addEventListener('orientationchange', this.formatSidebar); window.addEventListener('orientationchange', this.formatSidebar);
window.addEventListener('hashchange', this.setSectionAndPageFromUrl); window.addEventListener('hashchange', this.setSectionAndPageFromUrl);
@ -324,6 +333,7 @@ export default {
); );
}, },
beforeDestroy() { beforeDestroy() {
this.abortController.abort();
if (this.unlisten) { if (this.unlisten) {
this.unlisten(); this.unlisten();
} }
@ -387,8 +397,10 @@ export default {
this.lastLocalAnnotationCreation = this.domainObject.annotationLastCreated ?? 0; this.lastLocalAnnotationCreation = this.domainObject.annotationLastCreated ?? 0;
const foundAnnotations = await this.openmct.annotation.getAnnotations( const foundAnnotations = await this.openmct.annotation.getAnnotations(
this.domainObject.identifier this.domainObject.identifier,
this.abortController.signal
); );
foundAnnotations.forEach((foundAnnotation) => { foundAnnotations.forEach((foundAnnotation) => {
const targetId = Object.keys(foundAnnotation.targets)[0]; const targetId = Object.keys(foundAnnotation.targets)[0];
const entryId = foundAnnotation.targets[targetId].entryId; const entryId = foundAnnotation.targets[targetId].entryId;
@ -425,7 +437,11 @@ export default {
: [...filteredPageEntriesByTime].reverse(); : [...filteredPageEntriesByTime].reverse();
if (this.lastLocalAnnotationCreation < this.domainObject.annotationLastCreated) { if (this.lastLocalAnnotationCreation < this.domainObject.annotationLastCreated) {
this.loadAnnotations(); this.loadAnnotations().catch((err) => {
if (err.name !== 'AbortError') {
throw err;
}
});
} }
}, },
changeSelectedSection({ sectionId, pageId }) { changeSelectedSection({ sectionId, pageId }) {

View File

@ -27,7 +27,13 @@
// If the above namespace is ever resolved, we can fold this search provider // If the above namespace is ever resolved, we can fold this search provider
// back into the object provider. // back into the object provider.
const BATCH_ANNOTATION_DEBOUNCE_MS = 100;
class CouchSearchProvider { class CouchSearchProvider {
#bulkPromise;
#batchIds;
#lastAbortSignal;
constructor(couchObjectProvider) { constructor(couchObjectProvider) {
this.couchObjectProvider = couchObjectProvider; this.couchObjectProvider = couchObjectProvider;
this.searchTypes = couchObjectProvider.openmct.objects.SEARCH_TYPES; this.searchTypes = couchObjectProvider.openmct.objects.SEARCH_TYPES;
@ -36,6 +42,8 @@ class CouchSearchProvider {
this.searchTypes.ANNOTATIONS, this.searchTypes.ANNOTATIONS,
this.searchTypes.TAGS this.searchTypes.TAGS
]; ];
this.#batchIds = [];
this.#bulkPromise = null;
} }
supportsSearchType(searchType) { supportsSearchType(searchType) {
@ -68,28 +76,77 @@ class CouchSearchProvider {
return this.couchObjectProvider.getObjectsByFilter(filter, abortSignal); 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 = { const filter = {
selector: { selector: {
$and: [ $and: [
{
model: {
targets: {}
}
},
{ {
'model.type': { 'model.type': {
$eq: 'annotation' $eq: 'annotation'
} }
},
{
$or: []
} }
] ]
} }
}; };
filter.selector.$and[0].model.targets[keyString] = { let lastAbortSignal = null;
// TODO: should remove duplicates from batchIds
batchIdsToSearch.forEach(({ keyString, abortSignal }) => {
const modelFilter = {
model: {
targets: {}
}
};
modelFilter.model.targets[keyString] = {
$exists: true $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) { searchForTags(tagsArray, abortSignal) {

View File

@ -183,7 +183,7 @@ import MctTicks from './MctTicks.vue';
import MctChart from './chart/MctChart.vue'; import MctChart from './chart/MctChart.vue';
import XAxis from './axis/XAxis.vue'; import XAxis from './axis/XAxis.vue';
import YAxis from './axis/YAxis.vue'; import YAxis from './axis/YAxis.vue';
import KDBush from 'kdbush'; import Flatbush from 'flatbush';
import _ from 'lodash'; import _ from 'lodash';
const OFFSET_THRESHOLD = 10; const OFFSET_THRESHOLD = 10;
@ -339,6 +339,9 @@ export default {
this.cursorGuide = newCursorGuide; this.cursorGuide = newCursorGuide;
} }
}, },
created() {
this.abortController = new AbortController();
},
mounted() { mounted() {
this.yAxisIdVisibility = {}; this.yAxisIdVisibility = {};
this.offsetWidth = 0; this.offsetWidth = 0;
@ -398,6 +401,7 @@ export default {
this.loaded = true; this.loaded = true;
}, },
beforeDestroy() { beforeDestroy() {
this.abortController.abort();
this.openmct.selection.off('change', this.updateSelection); this.openmct.selection.off('change', this.updateSelection);
document.removeEventListener('keydown', this.handleKeyDown); document.removeEventListener('keydown', this.handleKeyDown);
document.removeEventListener('keyup', this.handleKeyUp); 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 // 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. // 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 selectionType = selection?.[0]?.[0]?.context?.type;
const validSelectionTypes = ['clicked-on-plot-selection', 'plot-annotation-search-result']; const validSelectionTypes = ['clicked-on-plot-selection', 'annotation-search-result'];
const isAnnotationSearchResult = selectionType === 'plot-annotation-search-result'; const isAnnotationSearchResult = selectionType === 'annotation-search-result';
if (!validSelectionTypes.includes(selectionType)) { if (!validSelectionTypes.includes(selectionType)) {
// wrong type of selection // wrong type of selection
@ -621,7 +625,8 @@ export default {
await Promise.all( await Promise.all(
this.seriesModels.map(async (seriesModel) => { this.seriesModels.map(async (seriesModel) => {
const seriesAnnotations = await this.openmct.annotation.getAnnotations( const seriesAnnotations = await this.openmct.annotation.getAnnotations(
seriesModel.model.identifier seriesModel.model.identifier,
this.abortController.signal
); );
rawAnnotationsForPlot.push(...seriesAnnotations); rawAnnotationsForPlot.push(...seriesAnnotations);
}) })
@ -1393,6 +1398,24 @@ export default {
return annotationsByPoints.flat(); 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) { getPointsInBox(boundingBoxPerYAxis, rawAnnotation) {
// load series models in KD-Trees // load series models in KD-Trees
const seriesKDTrees = []; const seriesKDTrees = [];
@ -1408,22 +1431,8 @@ export default {
const seriesData = seriesModel.getSeriesData(); const seriesData = seriesModel.getSeriesData();
if (seriesData && seriesData.length) { if (seriesData && seriesData.length) {
const kdTree = new KDBush(
seriesData,
(point) => {
return seriesModel.getXVal(point);
},
(point) => {
return seriesModel.getYVal(point);
}
);
const searchResults = []; const searchResults = [];
const rangeResults = kdTree.range( const rangeResults = this.searchWithFlatbush(seriesData, seriesModel, boundingBox);
boundingBox.minX,
boundingBox.minY,
boundingBox.maxX,
boundingBox.maxY
);
rangeResults.forEach((id) => { rangeResults.forEach((id) => {
const seriesDatum = seriesData[id]; const seriesDatum = seriesData[id];
if (seriesDatum) { if (seriesDatum) {
@ -1524,7 +1533,11 @@ export default {
this.endMarquee(); this.endMarquee();
} }
this.loadAnnotations(); this.loadAnnotations().catch((err) => {
if (err.name !== 'AbortError') {
throw err;
}
});
}, },
zoom(zoomDirection, zoomFactor) { zoom(zoomDirection, zoomFactor) {

View File

@ -20,13 +20,11 @@
* at runtime from the About dialog for additional information. * at runtime from the About dialog for additional information.
*****************************************************************************/ *****************************************************************************/
define([ import Vue from 'vue';
'objectUtils', import TableConfigurationComponent from './components/table-configuration.vue';
'./components/table-configuration.vue', import TelemetryTableConfiguration from './TelemetryTableConfiguration';
'./TelemetryTableConfiguration',
'vue' export default function TableConfigurationViewProvider(openmct) {
], function (objectUtils, TableConfigurationComponent, TelemetryTableConfiguration, Vue) {
function TableConfigurationViewProvider(openmct) {
return { return {
key: 'table-configuration', key: 'table-configuration',
name: 'Configuration', name: 'Configuration',
@ -41,11 +39,12 @@ define([
}, },
view: function (selection) { view: function (selection) {
let component; let component;
let domainObject = selection[0][0].context.item; let tableConfiguration;
let tableConfiguration = new TelemetryTableConfiguration(domainObject, openmct); const domainObject = selection[0][0].context.item;
return { return {
show: function (element) { show: function (element) {
tableConfiguration = new TelemetryTableConfiguration(domainObject, openmct);
component = new Vue({ component = new Vue({
el: element, el: element,
components: { components: {
@ -58,6 +57,9 @@ define([
template: '<table-configuration></table-configuration>' template: '<table-configuration></table-configuration>'
}); });
}, },
showTab: function (isEditing) {
return isEditing;
},
priority: function () { priority: function () {
return 1; return 1;
}, },
@ -73,6 +75,3 @@ define([
} }
}; };
} }
return TableConfigurationViewProvider;
});

View File

@ -84,12 +84,14 @@ export default {
configuration: this.tableConfiguration.getConfiguration() configuration: this.tableConfiguration.getConfiguration()
}; };
}, },
mounted() { async mounted() {
this.unlisteners = []; this.unlisteners = [];
this.openmct.editor.on('isEditing', this.toggleEdit); this.openmct.editor.on('isEditing', this.toggleEdit);
let compositionCollection = this.openmct.composition.get(this.tableConfiguration.domainObject); const compositionCollection = this.openmct.composition.get(
this.tableConfiguration.domainObject
);
compositionCollection.load().then((composition) => { const composition = await compositionCollection.load();
this.addColumnsForAllObjects(composition); this.addColumnsForAllObjects(composition);
this.updateHeaders(this.tableConfiguration.getAllHeaders()); this.updateHeaders(this.tableConfiguration.getAllHeaders());
@ -102,7 +104,6 @@ export default {
this.unlisteners.push( this.unlisteners.push(
compositionCollection.off.bind(compositionCollection, 'remove', this.removeObject) compositionCollection.off.bind(compositionCollection, 'remove', this.removeObject)
); );
});
}, },
destroyed() { destroyed() {
this.tableConfiguration.destroy(); this.tableConfiguration.destroy();

View File

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

View File

@ -98,6 +98,13 @@ export default {
} }
return 'Could not find any matching Notebook entries'; 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 { } else {
return this.result.targetModels[0].name; return this.result.targetModels[0].name;
} }
@ -115,11 +122,11 @@ export default {
mounted() { mounted() {
this.previewAction = new PreviewAction(this.openmct); this.previewAction = new PreviewAction(this.openmct);
this.previewAction.on('isVisible', this.togglePreviewState); this.previewAction.on('isVisible', this.togglePreviewState);
this.clickedPlotAnnotation = this.clickedPlotAnnotation.bind(this); this.fireAnnotationSelection = this.fireAnnotationSelection.bind(this);
}, },
destroyed() { destroyed() {
this.previewAction.off('isVisible', this.togglePreviewState); this.previewAction.off('isVisible', this.togglePreviewState);
this.openmct.selection.off('change', this.clickedPlotAnnotation); this.openmct.selection.off('change', this.fireAnnotationSelection);
}, },
methods: { methods: {
clickedResult(event) { clickedResult(event) {
@ -132,17 +139,15 @@ export default {
if (!this.openmct.router.isNavigatedObject(objectPath)) { if (!this.openmct.router.isNavigatedObject(objectPath)) {
// if we're not on the correct page, navigate to the object, // 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 // then wait for the selection event to fire before issuing a new selection
if ( if (this.result.annotationType) {
this.result.annotationType === this.openmct.annotation.ANNOTATION_TYPES.PLOT_SPATIAL this.openmct.selection.on('change', this.fireAnnotationSelection);
) {
this.openmct.selection.on('change', this.clickedPlotAnnotation);
} }
this.openmct.router.navigate(resultUrl); this.openmct.router.navigate(resultUrl);
} else { } else {
// if this is the navigated object, then we are already on the correct page // if this is the navigated object, then we are already on the correct page
// and just need to issue the selection event // and just need to issue the selection event
this.clickedPlotAnnotation(); this.fireAnnotationSelection();
} }
} }
}, },
@ -151,8 +156,8 @@ export default {
this.previewAction.invoke(objectPath); this.previewAction.invoke(objectPath);
} }
}, },
clickedPlotAnnotation() { fireAnnotationSelection() {
this.openmct.selection.off('change', this.clickedPlotAnnotation); this.openmct.selection.off('change', this.fireAnnotationSelection);
const targetDetails = {}; const targetDetails = {};
const targetDomainObjects = {}; const targetDomainObjects = {};
@ -168,11 +173,11 @@ export default {
element: this.$el, element: this.$el,
context: { context: {
item: this.result.targetModels[0], item: this.result.targetModels[0],
type: 'plot-annotation-search-result', type: 'annotation-search-result',
targetDetails, targetDetails,
targetDomainObjects, targetDomainObjects,
annotations: [this.result], annotations: [this.result],
annotationType: this.openmct.annotation.ANNOTATION_TYPES.PLOT_SPATIAL, annotationType: this.result.annotationType,
onAnnotationChange: () => {} onAnnotationChange: () => {}
} }
} }

View File

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