mirror of
https://github.com/nasa/openmct.git
synced 2024-12-26 08:11:05 +00:00
Merge branch 'master' into mct6555
This commit is contained in:
commit
de07e0326c
@ -232,9 +232,9 @@ jobs:
|
||||
workflows:
|
||||
overall-circleci-commit-status: #These jobs run on every commit
|
||||
jobs:
|
||||
# - lint:
|
||||
# name: node16-lint
|
||||
# node-version: lts/gallium
|
||||
- lint:
|
||||
name: node16-lint
|
||||
node-version: lts/gallium
|
||||
- unit-test:
|
||||
name: node18-chrome
|
||||
node-version: lts/hydrogen
|
||||
|
@ -4,3 +4,9 @@
|
||||
# Requires Git > 2.23
|
||||
# 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
|
||||
|
33
.github/workflows/e2e-couchdb.yml
vendored
33
.github/workflows/e2e-couchdb.yml
vendored
@ -7,15 +7,33 @@ on:
|
||||
- opened
|
||||
jobs:
|
||||
e2e-couchdb:
|
||||
if: ${{ github.event.label.name == 'pr:e2e:couchdb' }} || ${{ github.event.action == 'opened' }}
|
||||
if: github.event.label.name == 'pr:e2e:couchdb' || github.event.action == 'opened' && github.actor == 'dependabot[bot]'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 'lts/gallium'
|
||||
node-version: 'lts/hydrogen'
|
||||
|
||||
- name: Cache NPM dependencies
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: ~/.npm
|
||||
key: ${{ runner.os }}-node-${{ hashFiles('**/package.json') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-node-
|
||||
|
||||
- run: npm install --cache ~/.npm --prefer-offline --no-audit --progress=false
|
||||
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- run: npx playwright@1.32.3 install
|
||||
- run: npm install
|
||||
|
||||
- name: Start CouchDB Docker Container and Init with Setup Scripts
|
||||
run: |
|
||||
export $(cat src/plugins/persistence/couch/.env.ci | xargs)
|
||||
@ -23,26 +41,31 @@ jobs:
|
||||
sleep 3
|
||||
bash src/plugins/persistence/couch/setup-couchdb.sh
|
||||
bash src/plugins/persistence/couch/replace-localstorage-with-couchdb-indexhtml.sh
|
||||
|
||||
- name: Run CouchDB Tests and publish to deploysentinel
|
||||
env:
|
||||
DEPLOYSENTINEL_API_KEY: ${{ secrets.DEPLOYSENTINEL_API_KEY }}
|
||||
run: npm run test:e2e:couchdb
|
||||
|
||||
- name: Publish Results to Codecov.io
|
||||
env:
|
||||
SUPER_SECRET: ${{ secrets.CODECOV_TOKEN }}
|
||||
run: npm run cov:e2e:full:publish
|
||||
|
||||
- name: Archive test results
|
||||
if: success() || failure()
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
path: test-results
|
||||
|
||||
- name: Archive html test results
|
||||
if: success() || failure()
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
path: html-test-results
|
||||
|
||||
- name: Remove pr:e2e:couchdb label (if present)
|
||||
if: ${{ contains(github.event.pull_request.labels.*.name, 'pr:e2e:couchdb') }}
|
||||
if: always()
|
||||
uses: actions/github-script@v6
|
||||
with:
|
||||
script: |
|
||||
@ -56,5 +79,5 @@ jobs:
|
||||
name: labelToRemove
|
||||
});
|
||||
} catch (error) {
|
||||
core.warning(`Failed to remove 'pr:e2e:couchdb' label: ${error.message}`);
|
||||
core.warning(`Failed to remove ' + labelToRemove + ' label: ${error.message}`);
|
||||
}
|
||||
|
55
.github/workflows/e2e-pr.yml
vendored
55
.github/workflows/e2e-pr.yml
vendored
@ -7,31 +7,31 @@ on:
|
||||
- opened
|
||||
jobs:
|
||||
e2e-full:
|
||||
if: ${{ github.event.label.name == 'pr:e2e' }}
|
||||
if: github.event.label.name == 'pr:e2e' || github.event.action == 'opened' && github.actor == 'dependabot[bot]'
|
||||
runs-on: ${{ matrix.os }}
|
||||
timeout-minutes: 60
|
||||
strategy:
|
||||
matrix:
|
||||
os:
|
||||
- ubuntu-latest
|
||||
- windows-latest
|
||||
steps:
|
||||
- name: Trigger Success
|
||||
uses: actions/github-script@v6
|
||||
with:
|
||||
script: |
|
||||
github.rest.issues.createComment({
|
||||
issue_number: context.issue.number,
|
||||
owner: "nasa",
|
||||
repo: "openmct",
|
||||
body: 'Started e2e Run. Follow along: https://github.com/nasa/openmct/actions/runs/' + context.runId
|
||||
})
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: '16'
|
||||
node-version: 'lts/hydrogen'
|
||||
|
||||
- name: Cache NPM dependencies
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: ~/.npm
|
||||
key: ${{ runner.os }}-node-${{ hashFiles('**/package.json') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-node-
|
||||
|
||||
- run: npx playwright@1.32.3 install
|
||||
- run: npx playwright install chrome-beta
|
||||
- run: npm install
|
||||
- run: npm install --cache ~/.npm --prefer-offline --no-audit --progress=false
|
||||
- run: npm run test:e2e:full -- --max-failures=40
|
||||
- run: npm run cov:e2e:report || true
|
||||
- shell: bash
|
||||
@ -44,30 +44,9 @@ jobs:
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
path: test-results
|
||||
- name: Test success
|
||||
if: ${{ success() }}
|
||||
uses: actions/github-script@v6
|
||||
with:
|
||||
script: |
|
||||
github.rest.issues.createComment({
|
||||
issue_number: context.issue.number,
|
||||
owner: "nasa",
|
||||
repo: "openmct",
|
||||
body: 'Success ✅ ! Build artifacts are here: https://github.com/nasa/openmct/actions/runs/' + context.runId
|
||||
})
|
||||
- name: Test failure
|
||||
if: ${{ failure() }}
|
||||
uses: actions/github-script@v6
|
||||
with:
|
||||
script: |
|
||||
github.rest.issues.createComment({
|
||||
issue_number: context.issue.number,
|
||||
owner: "nasa",
|
||||
repo: "openmct",
|
||||
body: 'Failure ❌ ! Build artifacts are here: https://github.com/nasa/openmct/actions/runs/' + context.runId
|
||||
})
|
||||
|
||||
- name: Remove pr:e2e label (if present)
|
||||
if: ${{ contains(github.event.pull_request.labels.*.name, 'pr:e2e') }}
|
||||
if: always()
|
||||
uses: actions/github-script@v6
|
||||
with:
|
||||
script: |
|
||||
@ -81,5 +60,5 @@ jobs:
|
||||
name: labelToRemove
|
||||
});
|
||||
} catch (error) {
|
||||
core.warning(`Failed to remove 'pr:e2e' label: ${error.message}`);
|
||||
}
|
||||
core.warning(`Failed to remove ' + labelToRemove + ' label: ${error.message}`);
|
||||
}
|
4
.github/workflows/npm-prerelease.yml
vendored
4
.github/workflows/npm-prerelease.yml
vendored
@ -14,7 +14,7 @@ jobs:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 16
|
||||
node-version: lts/hydrogen
|
||||
- run: npm install
|
||||
- run: |
|
||||
echo "//registry.npmjs.org/:_authToken=$NODE_AUTH_TOKEN" >> ~/.npmrc
|
||||
@ -29,7 +29,7 @@ jobs:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 16
|
||||
node-version: lts/hydrogen
|
||||
registry-url: https://registry.npmjs.org/
|
||||
- run: npm install
|
||||
- run: npm publish --access=public --tag unstable
|
||||
|
46
.github/workflows/pr-platform.yml
vendored
46
.github/workflows/pr-platform.yml
vendored
@ -2,12 +2,15 @@ name: 'pr-platform'
|
||||
on:
|
||||
workflow_dispatch:
|
||||
pull_request:
|
||||
types: [labeled]
|
||||
types:
|
||||
- labeled
|
||||
- opened
|
||||
|
||||
jobs:
|
||||
e2e-full:
|
||||
if: ${{ github.event.label.name == 'pr:platform' }}
|
||||
pr-platform:
|
||||
if: github.event.label.name == 'pr:platform' || github.event.action == 'opened' && github.actor == 'dependabot[bot]'
|
||||
runs-on: ${{ matrix.os }}
|
||||
timeout-minutes: 60
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@ -16,18 +19,49 @@ jobs:
|
||||
- macos-latest
|
||||
- windows-latest
|
||||
node_version:
|
||||
- 16
|
||||
- 18
|
||||
- lts/gallium
|
||||
- lts/hydrogen
|
||||
architecture:
|
||||
- x64
|
||||
|
||||
name: Node ${{ matrix.node_version }} - ${{ matrix.architecture }} on ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- name: Setup node
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: ${{ matrix.node_version }}
|
||||
architecture: ${{ matrix.architecture }}
|
||||
- run: npm install
|
||||
|
||||
- name: Cache NPM dependencies
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: ~/.npm
|
||||
key: ${{ runner.os }}-${{ matrix.node_version }}-${{ hashFiles('**/package.json') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-${{ matrix.node_version }}-
|
||||
|
||||
- run: npm install --cache ~/.npm --prefer-offline --no-audit --progress=false
|
||||
|
||||
- run: npm test
|
||||
|
||||
- run: npm run lint -- --quiet
|
||||
|
||||
- name: Remove pr:platform label (if present)
|
||||
if: always()
|
||||
uses: actions/github-script@v6
|
||||
with:
|
||||
script: |
|
||||
const { owner, repo, number } = context.issue;
|
||||
const labelToRemove = 'pr:platform';
|
||||
try {
|
||||
await github.rest.issues.removeLabel({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: number,
|
||||
name: labelToRemove
|
||||
});
|
||||
} catch (error) {
|
||||
core.warning(`Failed to remove ' + labelToRemove + ' label: ${error.message}`);
|
||||
}
|
||||
|
@ -1,5 +1,6 @@
|
||||
{
|
||||
"trailingComma": "none",
|
||||
"singleQuote": true,
|
||||
"printWidth": 100
|
||||
"printWidth": 100,
|
||||
"endOfLine": "auto"
|
||||
}
|
||||
|
@ -67,7 +67,6 @@ const config = {
|
||||
MCT: path.join(projectRootDir, 'src/MCT'),
|
||||
testUtils: path.join(projectRootDir, 'src/utils/testUtils.js'),
|
||||
objectUtils: path.join(projectRootDir, 'src/api/objects/object-utils.js'),
|
||||
kdbush: path.join(projectRootDir, 'node_modules/kdbush/kdbush.min.js'),
|
||||
utils: path.join(projectRootDir, 'src/utils')
|
||||
}
|
||||
},
|
||||
|
122
e2e/tests/functional/planning/timelist.e2e.spec.js
Normal file
122
e2e/tests/functional/planning/timelist.e2e.spec.js
Normal 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('.');
|
||||
});
|
||||
});
|
||||
});
|
@ -205,6 +205,71 @@ test.describe('Display Layout', () => {
|
||||
|
||||
expect(await page.locator('.l-layout .l-layout__frame').count()).toEqual(0);
|
||||
});
|
||||
|
||||
test('When multiple plots are contained in a layout, we only ask for annotations once @couchdb', async ({
|
||||
page
|
||||
}) => {
|
||||
// Create another Sine Wave Generator
|
||||
const anotherSineWaveObject = await createDomainObjectWithDefaults(page, {
|
||||
type: 'Sine Wave Generator'
|
||||
});
|
||||
// Create a Display Layout
|
||||
await createDomainObjectWithDefaults(page, {
|
||||
type: 'Display Layout',
|
||||
name: 'Test Display Layout'
|
||||
});
|
||||
// Edit Display Layout
|
||||
await page.locator('[title="Edit"]').click();
|
||||
|
||||
// Expand the 'My Items' folder in the left tree
|
||||
await page.locator('.c-tree__item__view-control.c-disclosure-triangle').click();
|
||||
// Add the Sine Wave Generator to the Display Layout and save changes
|
||||
const treePane = page.getByRole('tree', {
|
||||
name: 'Main Tree'
|
||||
});
|
||||
const sineWaveGeneratorTreeItem = treePane.getByRole('treeitem', {
|
||||
name: new RegExp(sineWaveObject.name)
|
||||
});
|
||||
|
||||
let layoutGridHolder = page.locator('.l-layout__grid-holder');
|
||||
// eslint-disable-next-line playwright/no-force-option
|
||||
await sineWaveGeneratorTreeItem.dragTo(layoutGridHolder, { force: true });
|
||||
|
||||
await page.getByText('View type').click();
|
||||
await page.getByText('Overlay Plot').click();
|
||||
|
||||
const anotherSineWaveGeneratorTreeItem = treePane.getByRole('treeitem', {
|
||||
name: new RegExp(anotherSineWaveObject.name)
|
||||
});
|
||||
layoutGridHolder = page.locator('.l-layout__grid-holder');
|
||||
// eslint-disable-next-line playwright/no-force-option
|
||||
await anotherSineWaveGeneratorTreeItem.dragTo(layoutGridHolder, { force: true });
|
||||
|
||||
await page.getByText('View type').click();
|
||||
await page.getByText('Overlay Plot').click();
|
||||
|
||||
await page.locator('button[title="Save"]').click();
|
||||
await page.locator('text=Save and Finish Editing').click();
|
||||
|
||||
// Time to inspect some network traffic
|
||||
let networkRequests = [];
|
||||
page.on('request', (request) => {
|
||||
const searchRequest = request.url().endsWith('_find');
|
||||
const fetchRequest = request.resourceType() === 'fetch';
|
||||
if (searchRequest && fetchRequest) {
|
||||
networkRequests.push(request);
|
||||
}
|
||||
});
|
||||
|
||||
await page.reload();
|
||||
|
||||
// wait for annotations requests to be batched and requested
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// Network requests for the composite telemetry with multiple items should be:
|
||||
// 1. a single batched request for annotations
|
||||
expect(networkRequests.length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
|
@ -30,6 +30,7 @@ const { test, expect } = require('../../../../pluginFixtures');
|
||||
const { createDomainObjectWithDefaults } = require('../../../../appActions');
|
||||
const backgroundImageSelector = '.c-imagery__main-image__background-image';
|
||||
const panHotkey = process.platform === 'linux' ? ['Shift', 'Alt'] : ['Alt'];
|
||||
const tagHotkey = ['Shift', 'Alt'];
|
||||
const expectedAltText = process.platform === 'linux' ? 'Shift+Alt drag to pan' : 'Alt drag to pan';
|
||||
const thumbnailUrlParamsRegexp = /\?w=100&h=100/;
|
||||
|
||||
@ -44,7 +45,7 @@ test.describe('Example Imagery Object', () => {
|
||||
|
||||
// Verify that the created object is focused
|
||||
await expect(page.locator('.l-browse-bar__object-name')).toContainText(exampleImagery.name);
|
||||
await page.locator(backgroundImageSelector).hover({ trial: true });
|
||||
await page.locator('.c-imagery__main-image__bg').hover({ trial: true });
|
||||
});
|
||||
|
||||
test('Can use Mouse Wheel to zoom in and out of latest image', async ({ page }) => {
|
||||
@ -72,11 +73,11 @@ test.describe('Example Imagery Object', () => {
|
||||
test('Can use alt+drag to move around image once zoomed in', async ({ page }) => {
|
||||
const deltaYStep = 100; //equivalent to 1x zoom
|
||||
|
||||
await page.locator(backgroundImageSelector).hover({ trial: true });
|
||||
await page.locator('.c-imagery__main-image__bg').hover({ trial: true });
|
||||
|
||||
// zoom in
|
||||
await page.mouse.wheel(0, deltaYStep * 2);
|
||||
await page.locator(backgroundImageSelector).hover({ trial: true });
|
||||
await page.locator('.c-imagery__main-image__bg').hover({ trial: true });
|
||||
const zoomedBoundingBox = await page.locator(backgroundImageSelector).boundingBox();
|
||||
const imageCenterX = zoomedBoundingBox.x + zoomedBoundingBox.width / 2;
|
||||
const imageCenterY = zoomedBoundingBox.y + zoomedBoundingBox.height / 2;
|
||||
@ -131,6 +132,36 @@ test.describe('Example Imagery Object', () => {
|
||||
expect(afterDownPanBoundingBox.y).toBeLessThan(afterUpPanBoundingBox.y);
|
||||
});
|
||||
|
||||
test('Can use alt+shift+drag to create a tag', async ({ page }) => {
|
||||
const canvas = page.locator('canvas');
|
||||
await canvas.hover({ trial: true });
|
||||
|
||||
const canvasBoundingBox = await canvas.boundingBox();
|
||||
const canvasCenterX = canvasBoundingBox.x + canvasBoundingBox.width / 2;
|
||||
const canvasCenterY = canvasBoundingBox.y + canvasBoundingBox.height / 2;
|
||||
|
||||
await Promise.all(tagHotkey.map((x) => page.keyboard.down(x)));
|
||||
await page.mouse.down();
|
||||
// steps not working for me here
|
||||
await page.mouse.move(canvasCenterX - 20, canvasCenterY - 20);
|
||||
await page.mouse.move(canvasCenterX - 100, canvasCenterY - 100);
|
||||
await page.mouse.up();
|
||||
await Promise.all(tagHotkey.map((x) => page.keyboard.up(x)));
|
||||
|
||||
//Wait for canvas to stablize.
|
||||
await canvas.hover({ trial: true });
|
||||
|
||||
// add some tags
|
||||
await page.getByText('Annotations').click();
|
||||
await page.getByRole('button', { name: /Add Tag/ }).click();
|
||||
await page.getByPlaceholder('Type to select tag').click();
|
||||
await page.getByText('Driving').click();
|
||||
|
||||
await page.getByRole('button', { name: /Add Tag/ }).click();
|
||||
await page.getByPlaceholder('Type to select tag').click();
|
||||
await page.getByText('Science').click();
|
||||
});
|
||||
|
||||
test('Can use + - buttons to zoom on the image @unstable', async ({ page }) => {
|
||||
await buttonZoomOnImageAndAssert(page);
|
||||
});
|
||||
@ -185,24 +216,7 @@ test.describe('Example Imagery in Display Layout', () => {
|
||||
displayLayout = await createDomainObjectWithDefaults(page, { type: 'Display Layout' });
|
||||
await page.goto(displayLayout.url);
|
||||
|
||||
/* Create Sine Wave Generator with minimum Image Load Delay */
|
||||
// Click the Create button
|
||||
await page.click('button:has-text("Create")');
|
||||
|
||||
// Click text=Example Imagery
|
||||
await page.click('li[role="menuitem"]:has-text("Example Imagery")');
|
||||
|
||||
// Clear and set Image load delay to minimum value
|
||||
await page.locator('input[type="number"]').fill('');
|
||||
await page.locator('input[type="number"]').fill('5000');
|
||||
|
||||
// Click text=OK
|
||||
await Promise.all([
|
||||
page.waitForNavigation({ waitUntil: 'networkidle' }),
|
||||
page.click('button:has-text("OK")'),
|
||||
//Wait for Save Banner to appear
|
||||
page.waitForSelector('.c-message-banner__message')
|
||||
]);
|
||||
await createImageryView(page);
|
||||
|
||||
await expect(page.locator('.l-browse-bar__object-name')).toContainText(
|
||||
'Unnamed Example Imagery'
|
||||
@ -315,9 +329,47 @@ test.describe('Example Imagery in Display Layout', () => {
|
||||
await page.locator('div[title="Resize object height"] > input').click();
|
||||
await page.locator('div[title="Resize object height"] > input').fill('100');
|
||||
|
||||
expect(thumbsWrapperLocator.isVisible()).toBeTruthy();
|
||||
await expect(thumbsWrapperLocator).toBeVisible();
|
||||
await expect(thumbsWrapperLocator).not.toHaveClass(/is-small-thumbs/);
|
||||
});
|
||||
|
||||
/**
|
||||
* Toggle layer visibility checkbox by clicking on checkbox label
|
||||
* - should toggle checkbox and layer visibility for that image view
|
||||
* - should NOT toggle checkbox and layer visibity for the first image view in display
|
||||
*/
|
||||
test('Toggle layer visibility by clicking on label', async ({ page }) => {
|
||||
test.info().annotations.push({
|
||||
type: 'issue',
|
||||
description: 'https://github.com/nasa/openmct/issues/6709'
|
||||
});
|
||||
await createImageryView(page);
|
||||
await page.goto(displayLayout.url);
|
||||
|
||||
const imageElements = page.locator('.c-imagery__main-image-wrapper');
|
||||
|
||||
await expect(imageElements).toHaveCount(2);
|
||||
|
||||
const imageOne = page.locator('.c-imagery__main-image-wrapper').nth(0);
|
||||
const imageTwo = page.locator('.c-imagery__main-image-wrapper').nth(1);
|
||||
const imageOneWrapper = imageOne.locator('.image-wrapper');
|
||||
const imageTwoWrapper = imageTwo.locator('.image-wrapper');
|
||||
|
||||
await imageTwo.hover();
|
||||
|
||||
await imageTwo.locator('button[title="Layers"]').click();
|
||||
|
||||
const imageTwoLayersMenuContent = imageTwo.locator('button[title="Layers"] + div');
|
||||
const imageTwoLayersToggleLabel = imageTwoLayersMenuContent.locator('label').last();
|
||||
|
||||
await imageTwoLayersToggleLabel.click();
|
||||
|
||||
const imageOneLayers = imageOneWrapper.locator('.layer-image');
|
||||
const imageTwoLayers = imageTwoWrapper.locator('.layer-image');
|
||||
|
||||
await expect(imageOneLayers).toHaveCount(0);
|
||||
await expect(imageTwoLayers).toHaveCount(1);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Example Imagery in Flexible layout', () => {
|
||||
@ -692,7 +744,6 @@ async function panZoomAndAssertImageProperties(page) {
|
||||
async function mouseZoomOnImageAndAssert(page, factor = 2) {
|
||||
// Zoom in
|
||||
const originalImageDimensions = await page.locator(backgroundImageSelector).boundingBox();
|
||||
await page.locator(backgroundImageSelector).hover({ trial: true });
|
||||
const deltaYStep = 100; // equivalent to 1x zoom
|
||||
await page.mouse.wheel(0, deltaYStep * factor);
|
||||
const zoomedBoundingBox = await page.locator(backgroundImageSelector).boundingBox();
|
||||
@ -703,7 +754,7 @@ async function mouseZoomOnImageAndAssert(page, factor = 2) {
|
||||
await page.mouse.move(imageCenterX, imageCenterY);
|
||||
|
||||
// Wait for zoom animation to finish
|
||||
await page.locator(backgroundImageSelector).hover({ trial: true });
|
||||
await page.locator('.c-imagery__main-image__bg').hover({ trial: true });
|
||||
const imageMouseZoomed = await page.locator(backgroundImageSelector).boundingBox();
|
||||
|
||||
if (factor > 0) {
|
||||
@ -819,3 +870,26 @@ async function resetImageryPanAndZoom(page) {
|
||||
await panZoomResetBtn.click();
|
||||
await waitForAnimations(backgroundImage);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('@playwright/test').Page} page
|
||||
*/
|
||||
async function createImageryView(page) {
|
||||
// Click the Create button
|
||||
await page.click('button:has-text("Create")');
|
||||
|
||||
// Click text=Example Imagery
|
||||
await page.click('li[role="menuitem"]:has-text("Example Imagery")');
|
||||
|
||||
// Clear and set Image load delay to minimum value
|
||||
await page.locator('input[type="number"]').fill('');
|
||||
await page.locator('input[type="number"]').fill('5000');
|
||||
|
||||
// Click text=OK
|
||||
await Promise.all([
|
||||
page.waitForNavigation({ waitUntil: 'networkidle' }),
|
||||
page.click('button:has-text("OK")'),
|
||||
//Wait for Save Banner to appear
|
||||
page.waitForSelector('.c-message-banner__message')
|
||||
]);
|
||||
}
|
||||
|
42
package.json
42
package.json
@ -1,39 +1,40 @@
|
||||
{
|
||||
"name": "openmct",
|
||||
"version": "2.2.4-SNAPSHOT",
|
||||
"version": "2.2.6-SNAPSHOT",
|
||||
"description": "The Open MCT core platform",
|
||||
"devDependencies": {
|
||||
"@babel/eslint-parser": "7.19.1",
|
||||
"@babel/eslint-parser": "7.22.5",
|
||||
"@braintree/sanitize-url": "6.0.2",
|
||||
"@deploysentinel/playwright": "0.3.4",
|
||||
"@percy/cli": "1.24.0",
|
||||
"@percy/cli": "1.26.0",
|
||||
"@percy/playwright": "1.0.4",
|
||||
"@playwright/test": "1.32.3",
|
||||
"@types/eventemitter3": "1.2.0",
|
||||
"@types/jasmine": "4.3.1",
|
||||
"@types/jasmine": "4.3.4",
|
||||
"@types/lodash": "4.14.192",
|
||||
"babel-loader": "9.1.0",
|
||||
"babel-plugin-istanbul": "6.1.1",
|
||||
"codecov": "3.8.3",
|
||||
"comma-separated-values": "3.6.4",
|
||||
"copy-webpack-plugin": "11.0.0",
|
||||
"css-loader": "6.7.3",
|
||||
"css-loader": "6.8.1",
|
||||
"d3-axis": "3.0.0",
|
||||
"d3-scale": "3.3.0",
|
||||
"d3-selection": "3.0.0",
|
||||
"eslint": "8.40.0",
|
||||
"eslint": "8.43.0",
|
||||
"eslint-plugin-compat": "4.1.4",
|
||||
"eslint-config-prettier": "8.8.0",
|
||||
"eslint-plugin-playwright": "0.12.0",
|
||||
"eslint-plugin-prettier": "4.2.1",
|
||||
"eslint-plugin-vue": "9.13.0",
|
||||
"eslint-plugin-vue": "9.15.0",
|
||||
"eslint-plugin-you-dont-need-lodash-underscore": "6.12.0",
|
||||
"eventemitter3": "1.2.0",
|
||||
"file-saver": "2.0.5",
|
||||
"flatbush": "4.1.0",
|
||||
"git-rev-sync": "3.0.2",
|
||||
"html2canvas": "1.4.1",
|
||||
"imports-loader": "4.0.1",
|
||||
"jasmine-core": "4.5.0",
|
||||
"jasmine-core": "5.0.0",
|
||||
"karma": "6.4.2",
|
||||
"karma-chrome-launcher": "3.2.0",
|
||||
"karma-cli": "2.0.0",
|
||||
@ -44,10 +45,9 @@
|
||||
"karma-sourcemap-loader": "0.4.0",
|
||||
"karma-spec-reporter": "0.0.36",
|
||||
"karma-webpack": "5.0.0",
|
||||
"kdbush": "3.0.0",
|
||||
"location-bar": "3.0.1",
|
||||
"lodash": "4.17.21",
|
||||
"mini-css-extract-plugin": "2.7.5",
|
||||
"mini-css-extract-plugin": "2.7.6",
|
||||
"moment": "2.29.4",
|
||||
"moment-duration-format": "2.3.2",
|
||||
"moment-timezone": "0.5.41",
|
||||
@ -60,20 +60,20 @@
|
||||
"printj": "1.3.1",
|
||||
"resolve-url-loader": "5.0.0",
|
||||
"sanitize-html": "2.10.0",
|
||||
"sass": "1.62.1",
|
||||
"sass-loader": "13.2.2",
|
||||
"sinon": "15.0.1",
|
||||
"style-loader": "3.3.2",
|
||||
"typescript": "5.0.4",
|
||||
"sass": "1.63.4",
|
||||
"sass-loader": "13.3.2",
|
||||
"sinon": "15.1.0",
|
||||
"style-loader": "3.3.3",
|
||||
"typescript": "5.1.3",
|
||||
"uuid": "9.0.0",
|
||||
"vue": "2.6.14",
|
||||
"vue-eslint-parser": "9.2.1",
|
||||
"vue-eslint-parser": "9.3.1",
|
||||
"vue-loader": "15.9.8",
|
||||
"vue-template-compiler": "2.6.14",
|
||||
"webpack": "5.81.0",
|
||||
"webpack-cli": "5.0.2",
|
||||
"webpack-dev-server": "4.13.3",
|
||||
"webpack-merge": "5.8.0"
|
||||
"webpack": "5.86.0",
|
||||
"webpack-cli": "5.1.1",
|
||||
"webpack-dev-server": "4.15.1",
|
||||
"webpack-merge": "5.9.0"
|
||||
},
|
||||
"scripts": {
|
||||
"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"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16.19.1"
|
||||
"node": ">=16.19.1 <20"
|
||||
},
|
||||
"browserslist": [
|
||||
"Firefox ESR",
|
||||
|
@ -76,6 +76,9 @@ const ANNOTATION_LAST_CREATED = 'annotationLastCreated';
|
||||
* @constructor
|
||||
*/
|
||||
export default class AnnotationAPI extends EventEmitter {
|
||||
/** @type {Map<ANNOTATION_TYPES, Array<(a, b) => boolean >>} */
|
||||
#targetComparatorMap;
|
||||
|
||||
/**
|
||||
* @param {OpenMCT} openmct
|
||||
*/
|
||||
@ -84,6 +87,7 @@ export default class AnnotationAPI extends EventEmitter {
|
||||
this.openmct = openmct;
|
||||
this.availableTags = {};
|
||||
this.namespaceToSaveAnnotations = '';
|
||||
this.#targetComparatorMap = new Map();
|
||||
|
||||
this.ANNOTATION_TYPES = ANNOTATION_TYPES;
|
||||
this.ANNOTATION_TYPE = ANNOTATION_TYPE;
|
||||
@ -246,15 +250,16 @@ export default class AnnotationAPI extends EventEmitter {
|
||||
/**
|
||||
* @method getAnnotations
|
||||
* @param {Identifier} domainObjectIdentifier - The domain object identifier to use to search for annotations. For example, a telemetry object identifier.
|
||||
* @param {AbortSignal} abortSignal - An abort signal to cancel the search
|
||||
* @returns {DomainObject[]} Returns an array of annotations that match the search query
|
||||
*/
|
||||
async getAnnotations(domainObjectIdentifier) {
|
||||
async getAnnotations(domainObjectIdentifier, abortSignal = null) {
|
||||
const keyStringQuery = this.openmct.objects.makeKeyString(domainObjectIdentifier);
|
||||
const searchResults = (
|
||||
await Promise.all(
|
||||
this.openmct.objects.search(
|
||||
keyStringQuery,
|
||||
null,
|
||||
abortSignal,
|
||||
this.openmct.objects.SEARCH_TYPES.ANNOTATIONS
|
||||
)
|
||||
)
|
||||
@ -384,7 +389,8 @@ export default class AnnotationAPI extends EventEmitter {
|
||||
const combinedResults = [];
|
||||
results.forEach((currentAnnotation) => {
|
||||
const existingAnnotation = combinedResults.find((annotationToFind) => {
|
||||
return _.isEqual(currentAnnotation.targets, annotationToFind.targets);
|
||||
const { annotationType, targets } = currentAnnotation;
|
||||
return this.areAnnotationTargetsEqual(annotationType, targets, annotationToFind.targets);
|
||||
});
|
||||
if (!existingAnnotation) {
|
||||
combinedResults.push(currentAnnotation);
|
||||
@ -460,4 +466,35 @@ export default class AnnotationAPI extends EventEmitter {
|
||||
|
||||
return breakApartSeparateTargets;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a comparator function for a given annotation type.
|
||||
* The comparator functions will be used to determine if two annotations
|
||||
* have the same target.
|
||||
* @param {ANNOTATION_TYPES} annotationType
|
||||
* @param {(t1, t2) => boolean} comparator
|
||||
*/
|
||||
addTargetComparator(annotationType, comparator) {
|
||||
const comparatorList = this.#targetComparatorMap.get(annotationType) ?? [];
|
||||
comparatorList.push(comparator);
|
||||
this.#targetComparatorMap.set(annotationType, comparatorList);
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare two sets of targets to see if they are equal. First checks if
|
||||
* any targets comparators evaluate to true, then falls back to a deep
|
||||
* equality check.
|
||||
* @param {ANNOTATION_TYPES} annotationType
|
||||
* @param {*} targets
|
||||
* @param {*} otherTargets
|
||||
* @returns true if the targets are equal, false otherwise
|
||||
*/
|
||||
areAnnotationTargetsEqual(annotationType, targets, otherTargets) {
|
||||
const targetComparatorList = this.#targetComparatorMap.get(annotationType);
|
||||
return (
|
||||
(targetComparatorList?.length &&
|
||||
targetComparatorList.some((targetComparator) => targetComparator(targets, otherTargets))) ||
|
||||
_.isEqual(targets, otherTargets)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -265,4 +265,87 @@ describe('The Annotation API', () => {
|
||||
expect(results.length).toEqual(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Target Comparators', () => {
|
||||
let targets;
|
||||
let otherTargets;
|
||||
let comparator;
|
||||
|
||||
beforeEach(() => {
|
||||
targets = {
|
||||
fooTarget: {
|
||||
foo: 42
|
||||
}
|
||||
};
|
||||
otherTargets = {
|
||||
fooTarget: {
|
||||
bar: 42
|
||||
}
|
||||
};
|
||||
comparator = (t1, t2) => t1.fooTarget.foo === t2.fooTarget.bar;
|
||||
});
|
||||
});
|
||||
|
||||
it('can add a comparator function', () => {
|
||||
const notebookAnnotationType = openmct.annotation.ANNOTATION_TYPES.NOTEBOOK;
|
||||
expect(
|
||||
openmct.annotation.areAnnotationTargetsEqual(notebookAnnotationType, targets, otherTargets)
|
||||
).toBeFalse(); // without a comparator, these should NOT be equal
|
||||
// Register a comparator function for the notebook annotation type
|
||||
openmct.annotation.addTargetComparator(notebookAnnotationType, comparator);
|
||||
expect(
|
||||
openmct.annotation.areAnnotationTargetsEqual(notebookAnnotationType, targets, otherTargets)
|
||||
).toBeTrue(); // the comparator should make these equal
|
||||
});
|
||||
it('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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -28,6 +28,36 @@ import TelemetryValueFormatter from './TelemetryValueFormatter';
|
||||
import DefaultMetadataProvider from './DefaultMetadataProvider';
|
||||
import objectUtils from 'objectUtils';
|
||||
|
||||
/**
|
||||
* @typedef {import('../time/TimeContext').TimeContext} TimeContext
|
||||
*/
|
||||
|
||||
/**
|
||||
* Describes and bounds requests for telemetry data.
|
||||
*
|
||||
* @typedef TelemetryRequestOptions
|
||||
* @property {String} [sort] the key of the property to sort by. This may
|
||||
* be prefixed with a "+" or a "-" sign to sort in ascending
|
||||
* or descending order respectively. If no prefix is present,
|
||||
* ascending order will be used.
|
||||
* @property {Number} [start] the lower bound for values of the sorting property
|
||||
* @property {Number} [end] the upper bound for values of the sorting property
|
||||
* @property {String} [strategy] symbolic identifier for strategies
|
||||
* (such as `latest` or `minmax`) which may be recognized by providers;
|
||||
* these will be tried in order until an appropriate provider
|
||||
* is found
|
||||
* @property {AbortController} [signal] an AbortController which can be used
|
||||
* to cancel a telemetry request
|
||||
* @property {String} [domain] the domain key of the request
|
||||
* @property {TimeContext} [timeContext] the time context to use for this request
|
||||
* @memberof module:openmct.TelemetryAPI~
|
||||
*/
|
||||
|
||||
/**
|
||||
* Utilities for telemetry
|
||||
* @interface TelemetryAPI
|
||||
* @memberof module:openmct
|
||||
*/
|
||||
export default class TelemetryAPI {
|
||||
#isGreedyLAD;
|
||||
|
||||
@ -169,25 +199,35 @@ export default class TelemetryAPI {
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
* Though used in TelemetryCollection as well
|
||||
* @param {TelemetryRequestOptions} options options for the telemetry request
|
||||
* @returns {TelemetryRequestOptions} the options, with defaults filled in
|
||||
*/
|
||||
standardizeRequestOptions(options) {
|
||||
if (!Object.prototype.hasOwnProperty.call(options, 'start')) {
|
||||
options.start = this.openmct.time.bounds().start;
|
||||
standardizeRequestOptions(options = {}) {
|
||||
if (!Object.hasOwn(options, 'start')) {
|
||||
if (options.timeContext?.bounds()) {
|
||||
options.start = options.timeContext.bounds().start;
|
||||
} else {
|
||||
options.start = this.openmct.time.bounds().start;
|
||||
}
|
||||
}
|
||||
|
||||
if (!Object.prototype.hasOwnProperty.call(options, 'end')) {
|
||||
options.end = this.openmct.time.bounds().end;
|
||||
if (!Object.hasOwn(options, 'end')) {
|
||||
if (options.timeContext?.bounds()) {
|
||||
options.end = options.timeContext.bounds().end;
|
||||
} else {
|
||||
options.end = this.openmct.time.bounds().end;
|
||||
}
|
||||
}
|
||||
|
||||
if (!Object.prototype.hasOwnProperty.call(options, 'domain')) {
|
||||
if (!Object.hasOwn(options, 'domain')) {
|
||||
options.domain = this.openmct.time.timeSystem().key;
|
||||
}
|
||||
|
||||
if (!Object.prototype.hasOwnProperty.call(options, 'timeContext')) {
|
||||
if (!Object.hasOwn(options, 'timeContext')) {
|
||||
options.timeContext = this.openmct.time;
|
||||
}
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -265,7 +305,7 @@ export default class TelemetryAPI {
|
||||
* @memberof module:openmct.TelemetryAPI~TelemetryProvider#
|
||||
* @param {module:openmct.DomainObject} domainObject the object
|
||||
* which has associated telemetry
|
||||
* @param {module:openmct.TelemetryAPI~TelemetryRequest} options
|
||||
* @param {TelemetryRequestOptions} options
|
||||
* options for this telemetry collection request
|
||||
* @returns {TelemetryCollection} a TelemetryCollection instance
|
||||
*/
|
||||
@ -283,7 +323,7 @@ export default class TelemetryAPI {
|
||||
* @memberof module:openmct.TelemetryAPI~TelemetryProvider#
|
||||
* @param {module:openmct.DomainObject} domainObject the object
|
||||
* which has associated telemetry
|
||||
* @param {module:openmct.TelemetryAPI~TelemetryRequest} options
|
||||
* @param {TelemetryRequestOptions} options
|
||||
* options for this historical request
|
||||
* @returns {Promise.<object[]>} a promise for an array of
|
||||
* telemetry data
|
||||
@ -339,6 +379,7 @@ export default class TelemetryAPI {
|
||||
* @memberof module:openmct.TelemetryAPI~TelemetryProvider#
|
||||
* @param {module:openmct.DomainObject} domainObject the object
|
||||
* which has associated telemetry
|
||||
* @param {TelemetryRequestOptions} options configuration items for subscription
|
||||
* @param {Function} callback the callback to invoke with new data, as
|
||||
* it becomes available
|
||||
* @returns {Function} a function which may be called to terminate
|
||||
|
@ -24,6 +24,22 @@ import _ from 'lodash';
|
||||
import EventEmitter from 'EventEmitter';
|
||||
import { LOADED_ERROR, TIMESYSTEM_KEY_NOTIFICATION, TIMESYSTEM_KEY_WARNING } from './constants';
|
||||
|
||||
/**
|
||||
* @typedef {import('../objects/ObjectAPI').DomainObject} DomainObject
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {import('../time/TimeContext').TimeContext} TimeContext
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {import('./TelemetryAPI').TelemetryRequestOptions} TelemetryRequestOptions
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {import('../../../openmct').OpenMCT} OpenMCT
|
||||
*/
|
||||
|
||||
/** Class representing a Telemetry Collection. */
|
||||
|
||||
export default class TelemetryCollection extends EventEmitter {
|
||||
@ -31,10 +47,10 @@ export default class TelemetryCollection extends EventEmitter {
|
||||
* Creates a Telemetry Collection
|
||||
*
|
||||
* @param {OpenMCT} openmct - Open MCT
|
||||
* @param {module:openmct.DomainObject} domainObject - Domain Object to use for telemetry collection
|
||||
* @param {object} options - Any options passed in for request/subscribe
|
||||
* @param {DomainObject} domainObject - Domain Object to use for telemetry collection
|
||||
* @param {TelemetryRequestOptions} options - Any options passed in for request/subscribe
|
||||
*/
|
||||
constructor(openmct, domainObject, options) {
|
||||
constructor(openmct, domainObject, options = {}) {
|
||||
super();
|
||||
|
||||
this.loaded = false;
|
||||
@ -45,7 +61,7 @@ export default class TelemetryCollection extends EventEmitter {
|
||||
this.parseTime = undefined;
|
||||
this.metadata = this.openmct.telemetry.getMetadata(domainObject);
|
||||
this.unsubscribe = undefined;
|
||||
this.options = options;
|
||||
this.options = this.openmct.telemetry.standardizeRequestOptions(options);
|
||||
this.pageState = undefined;
|
||||
this.lastBounds = undefined;
|
||||
this.requestAbort = undefined;
|
||||
@ -62,8 +78,8 @@ export default class TelemetryCollection extends EventEmitter {
|
||||
this._error(LOADED_ERROR);
|
||||
}
|
||||
|
||||
this._setTimeSystem(this.openmct.time.timeSystem());
|
||||
this.lastBounds = this.openmct.time.bounds();
|
||||
this._setTimeSystem(this.options.timeContext.timeSystem());
|
||||
this.lastBounds = this.options.timeContext.bounds();
|
||||
|
||||
this._watchBounds();
|
||||
this._watchTimeSystem();
|
||||
@ -106,10 +122,10 @@ export default class TelemetryCollection extends EventEmitter {
|
||||
*/
|
||||
async _requestHistoricalTelemetry() {
|
||||
let options = { ...this.options };
|
||||
let historicalProvider;
|
||||
|
||||
this.openmct.telemetry.standardizeRequestOptions(options);
|
||||
historicalProvider = this.openmct.telemetry.findRequestProvider(this.domainObject, options);
|
||||
const historicalProvider = this.openmct.telemetry.findRequestProvider(
|
||||
this.domainObject,
|
||||
options
|
||||
);
|
||||
|
||||
if (!historicalProvider) {
|
||||
return;
|
||||
@ -438,7 +454,7 @@ export default class TelemetryCollection extends EventEmitter {
|
||||
* @private
|
||||
*/
|
||||
_watchBounds() {
|
||||
this.openmct.time.on('bounds', this._bounds, this);
|
||||
this.options.timeContext.on('bounds', this._bounds, this);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -446,7 +462,7 @@ export default class TelemetryCollection extends EventEmitter {
|
||||
* @private
|
||||
*/
|
||||
_unwatchBounds() {
|
||||
this.openmct.time.off('bounds', this._bounds, this);
|
||||
this.options.timeContext.off('bounds', this._bounds, this);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -454,7 +470,7 @@ export default class TelemetryCollection extends EventEmitter {
|
||||
* @private
|
||||
*/
|
||||
_watchTimeSystem() {
|
||||
this.openmct.time.on('timeSystem', this._setTimeSystemAndFetchData, this);
|
||||
this.options.timeContext.on('timeSystem', this._setTimeSystemAndFetchData, this);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -462,7 +478,7 @@ export default class TelemetryCollection extends EventEmitter {
|
||||
* @private
|
||||
*/
|
||||
_unwatchTimeSystem() {
|
||||
this.openmct.time.off('timeSystem', this._setTimeSystemAndFetchData, this);
|
||||
this.options.timeContext.off('timeSystem', this._setTimeSystemAndFetchData, this);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -135,7 +135,6 @@ class UserAPI extends EventEmitter {
|
||||
if (!this.hasProvider()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const activeRole = this.getActiveRole();
|
||||
|
||||
return this._provider.canProvideStatusForRole?.(activeRole);
|
||||
|
@ -49,10 +49,16 @@ define(['./components/FiltersView.vue', 'vue'], function (FiltersView, Vue) {
|
||||
});
|
||||
},
|
||||
showTab: function (isEditing) {
|
||||
const hasPersistedFilters = Boolean(domainObject?.configuration?.filters);
|
||||
const hasGlobalFilters = Boolean(domainObject?.configuration?.globalFilters);
|
||||
if (isEditing) {
|
||||
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 () {
|
||||
return openmct.priority.DEFAULT;
|
||||
|
53
src/plugins/filters/README.md
Normal file
53
src/plugins/filters/README.md
Normal 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.
|
@ -37,14 +37,37 @@
|
||||
:id="`${filter}filterControl`"
|
||||
class="c-input--flex"
|
||||
type="text"
|
||||
:aria-label="label"
|
||||
:disabled="useGlobal"
|
||||
:value="persistedValue(filter)"
|
||||
@change="updateFilterValue($event, filter)"
|
||||
@change="updateFilterValueFromString($event, filter)"
|
||||
/>
|
||||
</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 -->
|
||||
<template v-if="filter.possibleValues && isEditing">
|
||||
<template v-if="filter.possibleValues && isEditing && !filter.singleSelectionThreshold">
|
||||
<div
|
||||
v-for="option in filter.possibleValues"
|
||||
:key="option.value"
|
||||
@ -54,9 +77,10 @@
|
||||
:id="`${option.value}filterControl`"
|
||||
class="c-checkbox-list__input"
|
||||
type="checkbox"
|
||||
:aria-label="label"
|
||||
:disabled="useGlobal"
|
||||
:checked="isChecked(filter.comparator, option.value)"
|
||||
@change="updateFilterValue($event, filter.comparator, option.value)"
|
||||
:checked="isSelected(filter.comparator, option.value)"
|
||||
@change="updateFilterValueFromCheckbox($event, filter.comparator, option.value)"
|
||||
/>
|
||||
<span class="c-checkbox-list__value">
|
||||
{{ option.label }}
|
||||
@ -89,6 +113,10 @@ export default {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
label: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
useGlobal: Boolean,
|
||||
persistedFilters: {
|
||||
type: Object,
|
||||
@ -112,7 +140,7 @@ export default {
|
||||
toggleIsEditing(isEditing) {
|
||||
this.isEditing = isEditing;
|
||||
},
|
||||
isChecked(comparator, value) {
|
||||
isSelected(comparator, value) {
|
||||
if (this.persistedFilters[comparator] && this.persistedFilters[comparator].includes(value)) {
|
||||
return true;
|
||||
} else {
|
||||
@ -122,11 +150,17 @@ export default {
|
||||
persistedValue(comparator) {
|
||||
return this.persistedFilters && this.persistedFilters[comparator];
|
||||
},
|
||||
updateFilterValue(event, comparator, value) {
|
||||
if (value !== undefined) {
|
||||
this.$emit('filterSelected', this.filterField.key, comparator, value, event.target.checked);
|
||||
updateFilterValueFromString(event, comparator) {
|
||||
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('filterTextValueChanged', this.filterField.key, comparator, event.target.value);
|
||||
this.$emit('filterSingleSelected', this.filterField.key, comparator, value);
|
||||
}
|
||||
},
|
||||
getFilterLabels(filter) {
|
||||
|
@ -63,8 +63,11 @@
|
||||
:filter-field="metadatum"
|
||||
:use-global="persistedFilters.useGlobal"
|
||||
:persisted-filters="updatedFilters[metadatum.key]"
|
||||
@filterSelected="updateFiltersWithSelectedValue"
|
||||
label="Specific Filter"
|
||||
@filterSelected="updateMultipleFiltersWithSelectedValue"
|
||||
@filterTextValueChanged="updateFiltersWithTextValue"
|
||||
@filterSingleSelected="updateSingleSelection"
|
||||
@clearFilters="clearFilters"
|
||||
/>
|
||||
</ul>
|
||||
</div>
|
||||
@ -140,7 +143,7 @@ export default {
|
||||
toggleExpanded() {
|
||||
this.expanded = !this.expanded;
|
||||
},
|
||||
updateFiltersWithSelectedValue(key, comparator, valueName, value) {
|
||||
updateMultipleFiltersWithSelectedValue(key, comparator, valueName, value) {
|
||||
let filterValue = this.updatedFilters[key];
|
||||
|
||||
if (filterValue[comparator]) {
|
||||
@ -159,6 +162,10 @@ export default {
|
||||
|
||||
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) {
|
||||
if (value.trim() === '') {
|
||||
this.$set(this.updatedFilters, key, {});
|
||||
@ -168,6 +175,10 @@ export default {
|
||||
|
||||
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) {
|
||||
this.updatedFilters.useGlobal = checked;
|
||||
this.$emit('updateFilters', this.keyString, this.updatedFilters, checked);
|
||||
|
@ -43,8 +43,11 @@
|
||||
:key="metadatum.key"
|
||||
:filter-field="metadatum"
|
||||
:persisted-filters="updatedFilters[metadatum.key]"
|
||||
label="Global Filter"
|
||||
@filterSelected="updateFiltersWithSelectedValue"
|
||||
@filterTextValueChanged="updateFiltersWithTextValue"
|
||||
@filterSingleSelected="updateSingleSelection"
|
||||
@clearFilters="clearFilters"
|
||||
/>
|
||||
</ul>
|
||||
</li>
|
||||
@ -97,6 +100,10 @@ export default {
|
||||
toggleExpanded() {
|
||||
this.expanded = !this.expanded;
|
||||
},
|
||||
clearFilters(key) {
|
||||
this.$set(this.updatedFilters, key, {});
|
||||
this.$emit('persistGlobalFilters', key, this.updatedFilters);
|
||||
},
|
||||
updateFiltersWithSelectedValue(key, comparator, valueName, value) {
|
||||
let filterValue = this.updatedFilters[key];
|
||||
|
||||
@ -116,6 +123,10 @@ export default {
|
||||
|
||||
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) {
|
||||
if (value.trim() === '') {
|
||||
this.$set(this.updatedFilters, key, {});
|
||||
|
423
src/plugins/imagery/components/AnnotationsCanvas.vue
Normal file
423
src/plugins/imagery/components/AnnotationsCanvas.vue
Normal 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>
|
@ -19,7 +19,7 @@ $elemBg: rgba(black, 0.7);
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
z-index: 2;
|
||||
z-index: 3;
|
||||
@include userSelectNone;
|
||||
}
|
||||
|
||||
|
@ -137,7 +137,7 @@ export default {
|
||||
imageUrl(newUrl, oldUrl) {
|
||||
// reset image pan/zoom if newUrl only if not locked
|
||||
if (newUrl && !this.panZoomLocked) {
|
||||
this.$emit('resetImage');
|
||||
this.handleResetImage();
|
||||
}
|
||||
},
|
||||
cursorStates(states) {
|
||||
|
@ -38,6 +38,11 @@
|
||||
fetchpriority="low"
|
||||
@load="imageLoadCompleted"
|
||||
/>
|
||||
<i
|
||||
v-show="showAnnotationIndicator"
|
||||
class="c-thumb__annotation-indicator icon-status-poll-edit"
|
||||
>
|
||||
</i>
|
||||
</a>
|
||||
<div v-if="viewableArea" class="c-thumb__viewable-area" :style="viewableAreaStyle"></div>
|
||||
<div class="c-thumb__timestamp">{{ image.formattedTime }}</div>
|
||||
@ -66,6 +71,12 @@ export default {
|
||||
type: Boolean,
|
||||
required: true
|
||||
},
|
||||
imageryAnnotations: {
|
||||
type: Array,
|
||||
default() {
|
||||
return [];
|
||||
}
|
||||
},
|
||||
viewableArea: {
|
||||
type: Object,
|
||||
default: function () {
|
||||
@ -125,6 +136,11 @@ export default {
|
||||
width: `${width}px`,
|
||||
height: `${height}px`
|
||||
};
|
||||
},
|
||||
showAnnotationIndicator() {
|
||||
return this.imageryAnnotations.some((annotation) => {
|
||||
return !annotation._deleted;
|
||||
});
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
|
@ -88,6 +88,13 @@
|
||||
:image="focusedImage"
|
||||
:sized-image-dimensions="sizedImageDimensions"
|
||||
/>
|
||||
<AnnotationsCanvas
|
||||
v-if="shouldDisplayAnnotations"
|
||||
:image="focusedImage"
|
||||
:imagery-annotations="imageryAnnotations[focusedImage.time]"
|
||||
@annotationMarqueed="handlePauseButton(true)"
|
||||
@annotationsChanged="loadAnnotations"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -173,6 +180,7 @@
|
||||
:key="`${image.thumbnailUrl || image.url}-${image.time}-${index}`"
|
||||
:image="image"
|
||||
:active="focusedImageIndex === index"
|
||||
:imagery-annotations="imageryAnnotations[image.time]"
|
||||
:selected="focusedImageIndex === index && isPaused"
|
||||
:real-time="!isFixed"
|
||||
:viewable-area="focusedImageIndex === index ? viewableArea : null"
|
||||
@ -200,6 +208,7 @@ import Compass from './Compass/Compass.vue';
|
||||
import ImageControls from './ImageControls.vue';
|
||||
import ImageThumbnail from './ImageThumbnail.vue';
|
||||
import imageryData from '../../imagery/mixins/imageryData';
|
||||
import AnnotationsCanvas from './AnnotationsCanvas.vue';
|
||||
|
||||
const REFRESH_CSS_MS = 500;
|
||||
const DURATION_TRACK_MS = 1000;
|
||||
@ -232,7 +241,8 @@ export default {
|
||||
components: {
|
||||
Compass,
|
||||
ImageControls,
|
||||
ImageThumbnail
|
||||
ImageThumbnail,
|
||||
AnnotationsCanvas
|
||||
},
|
||||
mixins: [imageryData],
|
||||
inject: ['openmct', 'domainObject', 'objectPath', 'currentView', 'imageFreshnessOptions'],
|
||||
@ -295,7 +305,8 @@ export default {
|
||||
animateZoom: true,
|
||||
imagePanned: false,
|
||||
forceShowThumbnails: false,
|
||||
animateThumbScroll: false
|
||||
animateThumbScroll: false,
|
||||
imageryAnnotations: {}
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
@ -425,6 +436,19 @@ export default {
|
||||
|
||||
return result;
|
||||
},
|
||||
shouldDisplayAnnotations() {
|
||||
const imageHeightAndWidth = this.sizedImageHeight !== 0 && this.sizedImageWidth !== 0;
|
||||
const display =
|
||||
this.focusedImage !== undefined &&
|
||||
this.focusedImageNaturalAspectRatio !== undefined &&
|
||||
this.imageContainerWidth !== undefined &&
|
||||
this.imageContainerHeight !== undefined &&
|
||||
imageHeightAndWidth &&
|
||||
this.zoomFactor === 1 &&
|
||||
this.imagePanned !== true;
|
||||
|
||||
return display;
|
||||
},
|
||||
shouldDisplayCompass() {
|
||||
const imageHeightAndWidth = this.sizedImageHeight !== 0 && this.sizedImageWidth !== 0;
|
||||
const display =
|
||||
@ -631,6 +655,9 @@ export default {
|
||||
}
|
||||
}
|
||||
},
|
||||
created() {
|
||||
this.abortController = new AbortController();
|
||||
},
|
||||
async mounted() {
|
||||
eventHelpers.extend(this);
|
||||
this.focusedImageWrapper = this.$refs.focusedImageWrapper;
|
||||
@ -689,8 +716,12 @@ export default {
|
||||
|
||||
this.listenTo(this.focusedImageWrapper, 'wheel', this.wheelZoom, this);
|
||||
this.loadVisibleLayers();
|
||||
this.loadAnnotations();
|
||||
|
||||
this.openmct.selection.on('change', this.updateSelection);
|
||||
},
|
||||
beforeDestroy() {
|
||||
this.abortController.abort();
|
||||
this.persistVisibleLayers();
|
||||
this.stopFollowingTimeContext();
|
||||
|
||||
@ -716,6 +747,15 @@ export default {
|
||||
}
|
||||
|
||||
this.stopListening(this.focusedImageWrapper, 'wheel', this.wheelZoom, this);
|
||||
|
||||
Object.keys(this.imageryAnnotations).forEach((time) => {
|
||||
const imageAnnotationsForTime = this.imageryAnnotations[time];
|
||||
imageAnnotationsForTime.forEach((imageAnnotation) => {
|
||||
this.openmct.objects.destroyMutable(imageAnnotation);
|
||||
});
|
||||
});
|
||||
|
||||
this.openmct.selection.off('change', this.updateSelection);
|
||||
},
|
||||
methods: {
|
||||
calculateViewHeight() {
|
||||
@ -743,6 +783,15 @@ export default {
|
||||
this.timeContext.off('clock', this.trackDuration);
|
||||
}
|
||||
},
|
||||
updateSelection(selection) {
|
||||
const selectionType = selection?.[0]?.[0]?.context?.type;
|
||||
const validSelectionTypes = ['annotation-search-result'];
|
||||
|
||||
if (!validSelectionTypes.includes(selectionType)) {
|
||||
// wrong type of selection
|
||||
return;
|
||||
}
|
||||
},
|
||||
expand() {
|
||||
// check for modifier keys so it doesnt interfere with the layout
|
||||
if (this.cursorStates.modifierKeyPressed) {
|
||||
@ -832,6 +881,41 @@ export default {
|
||||
});
|
||||
}
|
||||
},
|
||||
async loadAnnotations(existingAnnotations) {
|
||||
if (!this.openmct.annotation.getAvailableTags().length) {
|
||||
// don't bother loading annotations if there are no tags
|
||||
return;
|
||||
}
|
||||
let foundAnnotations = existingAnnotations;
|
||||
if (!foundAnnotations) {
|
||||
// attempt to load
|
||||
foundAnnotations = await this.openmct.annotation.getAnnotations(
|
||||
this.domainObject.identifier,
|
||||
this.abortController.signal
|
||||
);
|
||||
}
|
||||
foundAnnotations.forEach((foundAnnotation) => {
|
||||
const targetId = Object.keys(foundAnnotation.targets)[0];
|
||||
const timeForAnnotation = foundAnnotation.targets[targetId].time;
|
||||
if (!this.imageryAnnotations[timeForAnnotation]) {
|
||||
this.$set(this.imageryAnnotations, timeForAnnotation, []);
|
||||
}
|
||||
|
||||
const annotationExtant = this.imageryAnnotations[timeForAnnotation].some(
|
||||
(existingAnnotation) => {
|
||||
return this.openmct.objects.areIdsEqual(
|
||||
existingAnnotation.identifier,
|
||||
foundAnnotation.identifier
|
||||
);
|
||||
}
|
||||
);
|
||||
if (!annotationExtant) {
|
||||
const annotationArray = this.imageryAnnotations[timeForAnnotation];
|
||||
const mutableAnnotation = this.openmct.objects.toMutable(foundAnnotation);
|
||||
annotationArray.push(mutableAnnotation);
|
||||
}
|
||||
});
|
||||
},
|
||||
persistVisibleLayers() {
|
||||
if (
|
||||
this.domainObject.configuration &&
|
||||
@ -979,7 +1063,9 @@ export default {
|
||||
}
|
||||
|
||||
await Vue.nextTick();
|
||||
this.$refs.thumbsWrapper.scrollLeft = scrollWidth;
|
||||
if (this.$refs.thumbsWrapper) {
|
||||
this.$refs.thumbsWrapper.scrollLeft = scrollWidth;
|
||||
}
|
||||
},
|
||||
scrollHandler() {
|
||||
if (this.isPaused) {
|
||||
|
@ -27,20 +27,14 @@
|
||||
<div class="c-checkbox-list js-checkbox-menu c-menu--to-left c-menu--has-close-btn">
|
||||
<ul @click="$event.stopPropagation()">
|
||||
<li v-for="(layer, index) in layers" :key="index">
|
||||
<input
|
||||
v-if="layer.visible"
|
||||
:id="index + 'LayerControl'"
|
||||
checked
|
||||
type="checkbox"
|
||||
@change="toggleLayerVisibility(index)"
|
||||
/>
|
||||
<input
|
||||
v-else
|
||||
:id="index + 'LayerControl'"
|
||||
type="checkbox"
|
||||
@change="toggleLayerVisibility(index)"
|
||||
/>
|
||||
<label :for="index + 'LayerControl'">{{ layer.name }}</label>
|
||||
<label>
|
||||
<input
|
||||
:checked="layer.visible"
|
||||
type="checkbox"
|
||||
@change="toggleLayerVisibility(index)"
|
||||
/>
|
||||
{{ layer.name }}
|
||||
</label>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
@ -293,6 +293,13 @@
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
&__annotation-indicator {
|
||||
color: $colorClickIconButton;
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
right: 8px;
|
||||
}
|
||||
|
||||
&__timestamp {
|
||||
flex: 0 0 auto;
|
||||
padding: 2px 3px;
|
||||
@ -540,3 +547,11 @@
|
||||
align-self: flex-end;
|
||||
}
|
||||
}
|
||||
|
||||
.c-image-canvas {
|
||||
pointer-events: auto; // This allows the image element to receive a browser-level context click
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
z-index: 2;
|
||||
}
|
||||
|
@ -60,7 +60,9 @@ export default {
|
||||
this.timeKey = this.timeSystem.key;
|
||||
this.timeFormatter = this.getFormatter(this.timeKey);
|
||||
|
||||
this.telemetryCollection = this.openmct.telemetry.requestCollection(this.domainObject, {});
|
||||
this.telemetryCollection = this.openmct.telemetry.requestCollection(this.domainObject, {
|
||||
timeContext: this.timeContext
|
||||
});
|
||||
this.telemetryCollection.on('add', this.dataAdded);
|
||||
this.telemetryCollection.on('remove', this.dataRemoved);
|
||||
this.telemetryCollection.on('clear', this.dataCleared);
|
||||
|
@ -41,7 +41,6 @@
|
||||
|
||||
<script>
|
||||
import TagEditor from './tags/TagEditor.vue';
|
||||
import _ from 'lodash';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
@ -123,11 +122,13 @@ export default {
|
||||
}
|
||||
},
|
||||
async mounted() {
|
||||
this.abortController = null;
|
||||
this.openmct.annotation.on('targetDomainObjectAnnotated', this.loadAnnotationForTargetObject);
|
||||
this.openmct.selection.on('change', this.updateSelection);
|
||||
await this.updateSelection(this.openmct.selection.get());
|
||||
},
|
||||
beforeDestroy() {
|
||||
this.openmct.annotation.off('targetDomainObjectAnnotated', this.loadAnnotationForTargetObject);
|
||||
this.openmct.selection.off('change', this.updateSelection);
|
||||
const unobserveEntryFunctions = Object.values(this.unobserveEntries);
|
||||
unobserveEntryFunctions.forEach((unobserveEntry) => {
|
||||
@ -189,20 +190,34 @@ export default {
|
||||
}
|
||||
},
|
||||
async loadAnnotationForTargetObject(target) {
|
||||
const targetID = this.openmct.objects.makeKeyString(target.identifier);
|
||||
const allAnnotationsForTarget = await this.openmct.annotation.getAnnotations(
|
||||
target.identifier
|
||||
);
|
||||
const filteredAnnotationsForSelection = allAnnotationsForTarget.filter((annotation) => {
|
||||
const matchingTargetID = Object.keys(annotation.targets).filter((loadedTargetID) => {
|
||||
return targetID === loadedTargetID;
|
||||
});
|
||||
const fetchedTargetDetails = annotation.targets[matchingTargetID];
|
||||
const selectedTargetDetails = this.targetDetails[matchingTargetID];
|
||||
// If the user changes targets while annotations are loading,
|
||||
// abort the previous request.
|
||||
if (this.abortController !== null) {
|
||||
this.abortController.abort();
|
||||
}
|
||||
|
||||
return _.isEqual(fetchedTargetDetails, selectedTargetDetails);
|
||||
});
|
||||
this.loadNewAnnotations(filteredAnnotationsForSelection);
|
||||
this.abortController = new AbortController();
|
||||
|
||||
try {
|
||||
const allAnnotationsForTarget = await this.openmct.annotation.getAnnotations(
|
||||
target.identifier,
|
||||
this.abortController.signal
|
||||
);
|
||||
const filteredAnnotationsForSelection = allAnnotationsForTarget.filter((annotation) =>
|
||||
this.openmct.annotation.areAnnotationTargetsEqual(
|
||||
this.annotationType,
|
||||
this.targetDetails,
|
||||
annotation.targets
|
||||
)
|
||||
);
|
||||
this.loadNewAnnotations(filteredAnnotationsForSelection);
|
||||
} catch (err) {
|
||||
if (err.name !== 'AbortError') {
|
||||
throw err;
|
||||
}
|
||||
} finally {
|
||||
this.abortController = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
@ -306,13 +306,22 @@ export default {
|
||||
this.getSearchResults = debounce(this.getSearchResults, 500);
|
||||
this.syncUrlWithPageAndSection = debounce(this.syncUrlWithPageAndSection, 100);
|
||||
},
|
||||
async mounted() {
|
||||
await this.loadAnnotations();
|
||||
async created() {
|
||||
this.transaction = null;
|
||||
this.abortController = new AbortController();
|
||||
try {
|
||||
await this.loadAnnotations();
|
||||
} catch (err) {
|
||||
if (err.name !== 'AbortError') {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.formatSidebar();
|
||||
this.setSectionAndPageFromUrl();
|
||||
|
||||
this.openmct.selection.on('change', this.updateSelection);
|
||||
this.transaction = null;
|
||||
|
||||
window.addEventListener('orientationchange', this.formatSidebar);
|
||||
window.addEventListener('hashchange', this.setSectionAndPageFromUrl);
|
||||
@ -324,6 +333,7 @@ export default {
|
||||
);
|
||||
},
|
||||
beforeDestroy() {
|
||||
this.abortController.abort();
|
||||
if (this.unlisten) {
|
||||
this.unlisten();
|
||||
}
|
||||
@ -387,8 +397,10 @@ export default {
|
||||
this.lastLocalAnnotationCreation = this.domainObject.annotationLastCreated ?? 0;
|
||||
|
||||
const foundAnnotations = await this.openmct.annotation.getAnnotations(
|
||||
this.domainObject.identifier
|
||||
this.domainObject.identifier,
|
||||
this.abortController.signal
|
||||
);
|
||||
|
||||
foundAnnotations.forEach((foundAnnotation) => {
|
||||
const targetId = Object.keys(foundAnnotation.targets)[0];
|
||||
const entryId = foundAnnotation.targets[targetId].entryId;
|
||||
@ -425,7 +437,11 @@ export default {
|
||||
: [...filteredPageEntriesByTime].reverse();
|
||||
|
||||
if (this.lastLocalAnnotationCreation < this.domainObject.annotationLastCreated) {
|
||||
this.loadAnnotations();
|
||||
this.loadAnnotations().catch((err) => {
|
||||
if (err.name !== 'AbortError') {
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
changeSelectedSection({ sectionId, pageId }) {
|
||||
|
@ -27,7 +27,13 @@
|
||||
// If the above namespace is ever resolved, we can fold this search provider
|
||||
// back into the object provider.
|
||||
|
||||
const BATCH_ANNOTATION_DEBOUNCE_MS = 100;
|
||||
|
||||
class CouchSearchProvider {
|
||||
#bulkPromise;
|
||||
#batchIds;
|
||||
#lastAbortSignal;
|
||||
|
||||
constructor(couchObjectProvider) {
|
||||
this.couchObjectProvider = couchObjectProvider;
|
||||
this.searchTypes = couchObjectProvider.openmct.objects.SEARCH_TYPES;
|
||||
@ -36,6 +42,8 @@ class CouchSearchProvider {
|
||||
this.searchTypes.ANNOTATIONS,
|
||||
this.searchTypes.TAGS
|
||||
];
|
||||
this.#batchIds = [];
|
||||
this.#bulkPromise = null;
|
||||
}
|
||||
|
||||
supportsSearchType(searchType) {
|
||||
@ -68,28 +76,77 @@ class CouchSearchProvider {
|
||||
return this.couchObjectProvider.getObjectsByFilter(filter, abortSignal);
|
||||
}
|
||||
|
||||
searchForAnnotations(keyString, abortSignal) {
|
||||
async #deferBatchAnnotationSearch() {
|
||||
// We until the next event loop cycle to "collect" all of the get
|
||||
// requests triggered in this iteration of the event loop
|
||||
await this.#waitForDebounce();
|
||||
const batchIdsToSearch = [...this.#batchIds];
|
||||
this.#clearBatch();
|
||||
return this.#bulkAnnotationSearch(batchIdsToSearch);
|
||||
}
|
||||
|
||||
#clearBatch() {
|
||||
this.#batchIds = [];
|
||||
this.#bulkPromise = undefined;
|
||||
}
|
||||
|
||||
#waitForDebounce() {
|
||||
let timeoutID;
|
||||
clearTimeout(timeoutID);
|
||||
|
||||
return new Promise((resolve) => {
|
||||
timeoutID = setTimeout(() => {
|
||||
resolve();
|
||||
}, BATCH_ANNOTATION_DEBOUNCE_MS);
|
||||
});
|
||||
}
|
||||
|
||||
#bulkAnnotationSearch(batchIdsToSearch) {
|
||||
const filter = {
|
||||
selector: {
|
||||
$and: [
|
||||
{
|
||||
model: {
|
||||
targets: {}
|
||||
}
|
||||
},
|
||||
{
|
||||
'model.type': {
|
||||
$eq: 'annotation'
|
||||
}
|
||||
},
|
||||
{
|
||||
$or: []
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
filter.selector.$and[0].model.targets[keyString] = {
|
||||
$exists: true
|
||||
};
|
||||
let lastAbortSignal = null;
|
||||
// TODO: should remove duplicates from batchIds
|
||||
batchIdsToSearch.forEach(({ keyString, abortSignal }) => {
|
||||
const modelFilter = {
|
||||
model: {
|
||||
targets: {}
|
||||
}
|
||||
};
|
||||
modelFilter.model.targets[keyString] = {
|
||||
$exists: true
|
||||
};
|
||||
|
||||
return this.couchObjectProvider.getObjectsByFilter(filter, abortSignal);
|
||||
filter.selector.$and[1].$or.push(modelFilter);
|
||||
lastAbortSignal = abortSignal;
|
||||
});
|
||||
|
||||
return this.couchObjectProvider.getObjectsByFilter(filter, lastAbortSignal);
|
||||
}
|
||||
|
||||
async searchForAnnotations(keyString, abortSignal) {
|
||||
this.#batchIds.push({ keyString, abortSignal });
|
||||
if (!this.#bulkPromise) {
|
||||
this.#bulkPromise = this.#deferBatchAnnotationSearch();
|
||||
}
|
||||
|
||||
const returnedData = await this.#bulkPromise;
|
||||
// only return data that matches the keystring
|
||||
const filteredByKeyString = returnedData.filter((foundAnnotation) => {
|
||||
return foundAnnotation.targets[keyString];
|
||||
});
|
||||
return filteredByKeyString;
|
||||
}
|
||||
|
||||
searchForTags(tagsArray, abortSignal) {
|
||||
|
@ -183,7 +183,7 @@ import MctTicks from './MctTicks.vue';
|
||||
import MctChart from './chart/MctChart.vue';
|
||||
import XAxis from './axis/XAxis.vue';
|
||||
import YAxis from './axis/YAxis.vue';
|
||||
import KDBush from 'kdbush';
|
||||
import Flatbush from 'flatbush';
|
||||
import _ from 'lodash';
|
||||
|
||||
const OFFSET_THRESHOLD = 10;
|
||||
@ -339,6 +339,9 @@ export default {
|
||||
this.cursorGuide = newCursorGuide;
|
||||
}
|
||||
},
|
||||
created() {
|
||||
this.abortController = new AbortController();
|
||||
},
|
||||
mounted() {
|
||||
this.yAxisIdVisibility = {};
|
||||
this.offsetWidth = 0;
|
||||
@ -398,6 +401,7 @@ export default {
|
||||
this.loaded = true;
|
||||
},
|
||||
beforeDestroy() {
|
||||
this.abortController.abort();
|
||||
this.openmct.selection.off('change', this.updateSelection);
|
||||
document.removeEventListener('keydown', this.handleKeyDown);
|
||||
document.removeEventListener('keyup', this.handleKeyUp);
|
||||
@ -410,8 +414,8 @@ export default {
|
||||
// on clicking on a search result we highlight the annotation and zoom - we know it's an annotation result when isAnnotationSearchResult === true
|
||||
// We shouldn't zoom when we're selecting existing annotations to view them or creating new annotations.
|
||||
const selectionType = selection?.[0]?.[0]?.context?.type;
|
||||
const validSelectionTypes = ['clicked-on-plot-selection', 'plot-annotation-search-result'];
|
||||
const isAnnotationSearchResult = selectionType === 'plot-annotation-search-result';
|
||||
const validSelectionTypes = ['clicked-on-plot-selection', 'annotation-search-result'];
|
||||
const isAnnotationSearchResult = selectionType === 'annotation-search-result';
|
||||
|
||||
if (!validSelectionTypes.includes(selectionType)) {
|
||||
// wrong type of selection
|
||||
@ -621,7 +625,8 @@ export default {
|
||||
await Promise.all(
|
||||
this.seriesModels.map(async (seriesModel) => {
|
||||
const seriesAnnotations = await this.openmct.annotation.getAnnotations(
|
||||
seriesModel.model.identifier
|
||||
seriesModel.model.identifier,
|
||||
this.abortController.signal
|
||||
);
|
||||
rawAnnotationsForPlot.push(...seriesAnnotations);
|
||||
})
|
||||
@ -1393,6 +1398,24 @@ export default {
|
||||
|
||||
return annotationsByPoints.flat();
|
||||
},
|
||||
searchWithFlatbush(seriesData, seriesModel, boundingBox) {
|
||||
const flatbush = new Flatbush(seriesData.length);
|
||||
seriesData.forEach((point) => {
|
||||
const x = seriesModel.getXVal(point);
|
||||
const y = seriesModel.getYVal(point);
|
||||
flatbush.add(x, y, x, y);
|
||||
});
|
||||
flatbush.finish();
|
||||
|
||||
const rangeResults = flatbush.search(
|
||||
boundingBox.minX,
|
||||
boundingBox.minY,
|
||||
boundingBox.maxX,
|
||||
boundingBox.maxY
|
||||
);
|
||||
|
||||
return rangeResults;
|
||||
},
|
||||
getPointsInBox(boundingBoxPerYAxis, rawAnnotation) {
|
||||
// load series models in KD-Trees
|
||||
const seriesKDTrees = [];
|
||||
@ -1408,22 +1431,8 @@ export default {
|
||||
|
||||
const seriesData = seriesModel.getSeriesData();
|
||||
if (seriesData && seriesData.length) {
|
||||
const kdTree = new KDBush(
|
||||
seriesData,
|
||||
(point) => {
|
||||
return seriesModel.getXVal(point);
|
||||
},
|
||||
(point) => {
|
||||
return seriesModel.getYVal(point);
|
||||
}
|
||||
);
|
||||
const searchResults = [];
|
||||
const rangeResults = kdTree.range(
|
||||
boundingBox.minX,
|
||||
boundingBox.minY,
|
||||
boundingBox.maxX,
|
||||
boundingBox.maxY
|
||||
);
|
||||
const rangeResults = this.searchWithFlatbush(seriesData, seriesModel, boundingBox);
|
||||
rangeResults.forEach((id) => {
|
||||
const seriesDatum = seriesData[id];
|
||||
if (seriesDatum) {
|
||||
@ -1524,7 +1533,11 @@ export default {
|
||||
this.endMarquee();
|
||||
}
|
||||
|
||||
this.loadAnnotations();
|
||||
this.loadAnnotations().catch((err) => {
|
||||
if (err.name !== 'AbortError') {
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
zoom(zoomDirection, zoomFactor) {
|
||||
|
@ -20,59 +20,58 @@
|
||||
* at runtime from the About dialog for additional information.
|
||||
*****************************************************************************/
|
||||
|
||||
define([
|
||||
'objectUtils',
|
||||
'./components/table-configuration.vue',
|
||||
'./TelemetryTableConfiguration',
|
||||
'vue'
|
||||
], function (objectUtils, TableConfigurationComponent, TelemetryTableConfiguration, Vue) {
|
||||
function TableConfigurationViewProvider(openmct) {
|
||||
return {
|
||||
key: 'table-configuration',
|
||||
name: 'Configuration',
|
||||
canView: function (selection) {
|
||||
if (selection.length !== 1 || selection[0].length === 0) {
|
||||
return false;
|
||||
}
|
||||
import Vue from 'vue';
|
||||
import TableConfigurationComponent from './components/table-configuration.vue';
|
||||
import TelemetryTableConfiguration from './TelemetryTableConfiguration';
|
||||
|
||||
let object = selection[0][0].context.item;
|
||||
|
||||
return object && object.type === 'table';
|
||||
},
|
||||
view: function (selection) {
|
||||
let component;
|
||||
let domainObject = selection[0][0].context.item;
|
||||
let tableConfiguration = new TelemetryTableConfiguration(domainObject, openmct);
|
||||
|
||||
return {
|
||||
show: function (element) {
|
||||
component = new Vue({
|
||||
el: element,
|
||||
components: {
|
||||
TableConfiguration: TableConfigurationComponent.default
|
||||
},
|
||||
provide: {
|
||||
openmct,
|
||||
tableConfiguration
|
||||
},
|
||||
template: '<table-configuration></table-configuration>'
|
||||
});
|
||||
},
|
||||
priority: function () {
|
||||
return 1;
|
||||
},
|
||||
destroy: function () {
|
||||
if (component) {
|
||||
component.$destroy();
|
||||
component = undefined;
|
||||
}
|
||||
|
||||
tableConfiguration = undefined;
|
||||
}
|
||||
};
|
||||
export default function TableConfigurationViewProvider(openmct) {
|
||||
return {
|
||||
key: 'table-configuration',
|
||||
name: 'Configuration',
|
||||
canView: function (selection) {
|
||||
if (selection.length !== 1 || selection[0].length === 0) {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return TableConfigurationViewProvider;
|
||||
});
|
||||
let object = selection[0][0].context.item;
|
||||
|
||||
return object && object.type === 'table';
|
||||
},
|
||||
view: function (selection) {
|
||||
let component;
|
||||
let tableConfiguration;
|
||||
const domainObject = selection[0][0].context.item;
|
||||
|
||||
return {
|
||||
show: function (element) {
|
||||
tableConfiguration = new TelemetryTableConfiguration(domainObject, openmct);
|
||||
component = new Vue({
|
||||
el: element,
|
||||
components: {
|
||||
TableConfiguration: TableConfigurationComponent.default
|
||||
},
|
||||
provide: {
|
||||
openmct,
|
||||
tableConfiguration
|
||||
},
|
||||
template: '<table-configuration></table-configuration>'
|
||||
});
|
||||
},
|
||||
showTab: function (isEditing) {
|
||||
return isEditing;
|
||||
},
|
||||
priority: function () {
|
||||
return 1;
|
||||
},
|
||||
destroy: function () {
|
||||
if (component) {
|
||||
component.$destroy();
|
||||
component = undefined;
|
||||
}
|
||||
|
||||
tableConfiguration = undefined;
|
||||
}
|
||||
};
|
||||
}
|
||||
};
|
||||
}
|
||||
|
@ -84,25 +84,26 @@ export default {
|
||||
configuration: this.tableConfiguration.getConfiguration()
|
||||
};
|
||||
},
|
||||
mounted() {
|
||||
async mounted() {
|
||||
this.unlisteners = [];
|
||||
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) => {
|
||||
this.addColumnsForAllObjects(composition);
|
||||
this.updateHeaders(this.tableConfiguration.getAllHeaders());
|
||||
const composition = await compositionCollection.load();
|
||||
this.addColumnsForAllObjects(composition);
|
||||
this.updateHeaders(this.tableConfiguration.getAllHeaders());
|
||||
|
||||
compositionCollection.on('add', this.addObject);
|
||||
this.unlisteners.push(
|
||||
compositionCollection.off.bind(compositionCollection, 'add', this.addObject)
|
||||
);
|
||||
compositionCollection.on('add', this.addObject);
|
||||
this.unlisteners.push(
|
||||
compositionCollection.off.bind(compositionCollection, 'add', this.addObject)
|
||||
);
|
||||
|
||||
compositionCollection.on('remove', this.removeObject);
|
||||
this.unlisteners.push(
|
||||
compositionCollection.off.bind(compositionCollection, 'remove', this.removeObject)
|
||||
);
|
||||
});
|
||||
compositionCollection.on('remove', this.removeObject);
|
||||
this.unlisteners.push(
|
||||
compositionCollection.off.bind(compositionCollection, 'remove', this.removeObject)
|
||||
);
|
||||
},
|
||||
destroyed() {
|
||||
this.tableConfiguration.destroy();
|
||||
|
@ -41,8 +41,10 @@ import moment from 'moment';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
const SCROLL_TIMEOUT = 10000;
|
||||
const ROW_HEIGHT = 30;
|
||||
const TIME_FORMAT = 'YYYY-MM-DD HH:mm:ss:SSS';
|
||||
const TIME_FORMAT = 'YYYY-MM-DD HH:mm:ss';
|
||||
const CURRENT_CSS_SUFFIX = '--is-current';
|
||||
const PAST_CSS_SUFFIX = '--is-past';
|
||||
const FUTURE_CSS_SUFFIX = '--is-future';
|
||||
const headerItems = [
|
||||
{
|
||||
defaultDirection: true,
|
||||
@ -79,9 +81,9 @@ const headerItems = [
|
||||
format: function (value) {
|
||||
let result;
|
||||
if (value < 0) {
|
||||
result = `-${getPreciseDuration(Math.abs(value))}`;
|
||||
result = `+${getPreciseDuration(Math.abs(value), true)}`;
|
||||
} else if (value > 0) {
|
||||
result = `+${getPreciseDuration(value)}`;
|
||||
result = `-${getPreciseDuration(value, true)}`;
|
||||
} else {
|
||||
result = 'Now';
|
||||
}
|
||||
@ -360,11 +362,12 @@ export default {
|
||||
groups.forEach((key) => {
|
||||
activities = activities.concat(this.planData[key]);
|
||||
});
|
||||
activities = activities.filter(this.filterActivities);
|
||||
// filter activities first, then sort by start time
|
||||
activities = activities.filter(this.filterActivities).sort(this.sortByStartTime);
|
||||
activities = this.applyStyles(activities);
|
||||
this.setScrollTop();
|
||||
// sort by start time
|
||||
this.planActivities = activities.sort(this.sortByStartTime);
|
||||
this.planActivities = activities;
|
||||
//We need to wait for the next tick since we need the height of the row from the DOM
|
||||
this.$nextTick(this.setScrollTop);
|
||||
},
|
||||
updateTimeStampAndListActivities(time) {
|
||||
this.timestamp = time;
|
||||
@ -410,30 +413,41 @@ export default {
|
||||
},
|
||||
applyStyles(activities) {
|
||||
let firstCurrentActivityIndex = -1;
|
||||
let activityClosestToNowIndex = -1;
|
||||
let currentActivitiesCount = 0;
|
||||
const styledActivities = activities.map((activity, index) => {
|
||||
if (this.timestamp >= activity.start && this.timestamp <= activity.end) {
|
||||
activity.cssClass = '--is-current';
|
||||
activity.cssClass = CURRENT_CSS_SUFFIX;
|
||||
if (firstCurrentActivityIndex < 0) {
|
||||
firstCurrentActivityIndex = index;
|
||||
}
|
||||
|
||||
currentActivitiesCount = currentActivitiesCount + 1;
|
||||
} else if (this.timestamp < activity.start) {
|
||||
activity.cssClass = '--is-future';
|
||||
activity.cssClass = FUTURE_CSS_SUFFIX;
|
||||
//the index of the first activity that's greater than the current timestamp
|
||||
if (activityClosestToNowIndex < 0) {
|
||||
activityClosestToNowIndex = index;
|
||||
}
|
||||
} else {
|
||||
activity.cssClass = '--is-past';
|
||||
activity.cssClass = PAST_CSS_SUFFIX;
|
||||
}
|
||||
|
||||
if (!activity.key) {
|
||||
activity.key = uuid();
|
||||
}
|
||||
|
||||
activity.duration = activity.start - this.timestamp;
|
||||
if (activity.start < this.timestamp) {
|
||||
//if the activity start time has passed, display the time to the end of the activity
|
||||
activity.duration = activity.end - this.timestamp;
|
||||
} else {
|
||||
activity.duration = activity.start - this.timestamp;
|
||||
}
|
||||
|
||||
return activity;
|
||||
});
|
||||
|
||||
this.activityClosestToNowIndex = activityClosestToNowIndex;
|
||||
this.firstCurrentActivityIndex = firstCurrentActivityIndex;
|
||||
this.currentActivitiesCount = currentActivitiesCount;
|
||||
|
||||
@ -451,13 +465,22 @@ export default {
|
||||
}
|
||||
|
||||
this.firstCurrentActivityIndex = -1;
|
||||
this.activityClosestToNowIndex = -1;
|
||||
this.currentActivitiesCount = 0;
|
||||
this.$el.parentElement?.scrollTo({ top: 0 });
|
||||
this.autoScrolled = false;
|
||||
},
|
||||
setScrollTop() {
|
||||
//scroll to somewhere mid-way of the current activities
|
||||
if (this.firstCurrentActivityIndex > -1) {
|
||||
//The view isn't ready yet
|
||||
if (!this.$el.parentElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
const row = this.$el.querySelector('.js-list-item');
|
||||
if (row && this.firstCurrentActivityIndex > -1) {
|
||||
// scroll to somewhere mid-way of the current activities
|
||||
const ROW_HEIGHT = row.getBoundingClientRect().height;
|
||||
|
||||
if (this.canAutoScroll() === false) {
|
||||
return;
|
||||
}
|
||||
@ -469,7 +492,22 @@ export default {
|
||||
behavior: 'smooth'
|
||||
});
|
||||
this.autoScrolled = false;
|
||||
} else if (row && this.activityClosestToNowIndex > -1) {
|
||||
// scroll to somewhere close to 'now'
|
||||
|
||||
const ROW_HEIGHT = row.getBoundingClientRect().height;
|
||||
|
||||
if (this.canAutoScroll() === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.$el.parentElement.scrollTo({
|
||||
top: ROW_HEIGHT * (this.activityClosestToNowIndex - 1),
|
||||
behavior: 'smooth'
|
||||
});
|
||||
this.autoScrolled = false;
|
||||
} else {
|
||||
// scroll to the top
|
||||
this.resetScroll();
|
||||
}
|
||||
},
|
||||
|
@ -219,10 +219,10 @@ describe('the plugin', function () {
|
||||
'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua'
|
||||
);
|
||||
expect(itemValues[0].innerHTML.trim()).toEqual(
|
||||
`${moment(twoHoursPast).format('YYYY-MM-DD HH:mm:ss:SSS')}Z`
|
||||
`${moment(twoHoursPast).format('YYYY-MM-DD HH:mm:ss')}Z`
|
||||
);
|
||||
expect(itemValues[1].innerHTML.trim()).toEqual(
|
||||
`${moment(oneHourPast).format('YYYY-MM-DD HH:mm:ss:SSS')}Z`
|
||||
`${moment(oneHourPast).format('YYYY-MM-DD HH:mm:ss')}Z`
|
||||
);
|
||||
|
||||
done();
|
||||
|
@ -98,6 +98,13 @@ export default {
|
||||
}
|
||||
|
||||
return 'Could not find any matching Notebook entries';
|
||||
} else if (
|
||||
this.result.annotationType === this.openmct.annotation.ANNOTATION_TYPES.GEOSPATIAL
|
||||
) {
|
||||
const targetID = Object.keys(this.result.targets)[0];
|
||||
const { layerName, name } = this.result.targets[targetID];
|
||||
|
||||
return layerName ? `${layerName} - ${name}` : name;
|
||||
} else {
|
||||
return this.result.targetModels[0].name;
|
||||
}
|
||||
@ -115,11 +122,11 @@ export default {
|
||||
mounted() {
|
||||
this.previewAction = new PreviewAction(this.openmct);
|
||||
this.previewAction.on('isVisible', this.togglePreviewState);
|
||||
this.clickedPlotAnnotation = this.clickedPlotAnnotation.bind(this);
|
||||
this.fireAnnotationSelection = this.fireAnnotationSelection.bind(this);
|
||||
},
|
||||
destroyed() {
|
||||
this.previewAction.off('isVisible', this.togglePreviewState);
|
||||
this.openmct.selection.off('change', this.clickedPlotAnnotation);
|
||||
this.openmct.selection.off('change', this.fireAnnotationSelection);
|
||||
},
|
||||
methods: {
|
||||
clickedResult(event) {
|
||||
@ -132,17 +139,15 @@ export default {
|
||||
if (!this.openmct.router.isNavigatedObject(objectPath)) {
|
||||
// if we're not on the correct page, navigate to the object,
|
||||
// then wait for the selection event to fire before issuing a new selection
|
||||
if (
|
||||
this.result.annotationType === this.openmct.annotation.ANNOTATION_TYPES.PLOT_SPATIAL
|
||||
) {
|
||||
this.openmct.selection.on('change', this.clickedPlotAnnotation);
|
||||
if (this.result.annotationType) {
|
||||
this.openmct.selection.on('change', this.fireAnnotationSelection);
|
||||
}
|
||||
|
||||
this.openmct.router.navigate(resultUrl);
|
||||
} else {
|
||||
// if this is the navigated object, then we are already on the correct page
|
||||
// and just need to issue the selection event
|
||||
this.clickedPlotAnnotation();
|
||||
this.fireAnnotationSelection();
|
||||
}
|
||||
}
|
||||
},
|
||||
@ -151,8 +156,8 @@ export default {
|
||||
this.previewAction.invoke(objectPath);
|
||||
}
|
||||
},
|
||||
clickedPlotAnnotation() {
|
||||
this.openmct.selection.off('change', this.clickedPlotAnnotation);
|
||||
fireAnnotationSelection() {
|
||||
this.openmct.selection.off('change', this.fireAnnotationSelection);
|
||||
|
||||
const targetDetails = {};
|
||||
const targetDomainObjects = {};
|
||||
@ -168,11 +173,11 @@ export default {
|
||||
element: this.$el,
|
||||
context: {
|
||||
item: this.result.targetModels[0],
|
||||
type: 'plot-annotation-search-result',
|
||||
type: 'annotation-search-result',
|
||||
targetDetails,
|
||||
targetDomainObjects,
|
||||
annotations: [this.result],
|
||||
annotationType: this.openmct.annotation.ANNOTATION_TYPES.PLOT_SPATIAL,
|
||||
annotationType: this.result.annotationType,
|
||||
onAnnotationChange: () => {}
|
||||
}
|
||||
}
|
||||
|
@ -63,14 +63,16 @@ export function millisecondsToDHMS(numericDuration) {
|
||||
return `${dhms ? '+' : ''} ${dhms}`;
|
||||
}
|
||||
|
||||
export function getPreciseDuration(value) {
|
||||
export function getPreciseDuration(value, excludeMilliSeconds) {
|
||||
const ms = value || 0;
|
||||
|
||||
return [
|
||||
const duration = [
|
||||
toDoubleDigits(Math.floor(normalizeAge(ms / ONE_DAY))),
|
||||
toDoubleDigits(Math.floor(normalizeAge((ms % ONE_DAY) / ONE_HOUR))),
|
||||
toDoubleDigits(Math.floor(normalizeAge((ms % ONE_HOUR) / ONE_MINUTE))),
|
||||
toDoubleDigits(Math.floor(normalizeAge((ms % ONE_MINUTE) / ONE_SECOND))),
|
||||
toTripleDigits(Math.floor(normalizeAge(ms % ONE_SECOND)))
|
||||
].join(':');
|
||||
toDoubleDigits(Math.floor(normalizeAge((ms % ONE_MINUTE) / ONE_SECOND)))
|
||||
];
|
||||
if (!excludeMilliSeconds) {
|
||||
duration.push(toTripleDigits(Math.floor(normalizeAge(ms % ONE_SECOND))));
|
||||
}
|
||||
return duration.join(':');
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user