feat: configurable Plan Views for reducing vertical scroll distance (#6415)

* refactor: convert Type API to ES6 module

- Another AMD module bites the dust 🧹

* feat: add initial configurable plan type

- Name change TBD

* feat: add `clipActivityNames` property

- refactor: initialize data to `null`

* refactor: general code cleanup

* feat(WIP): name clipping via clipPath elements

* feat: compose a Gantt Chart using a Plan

- Allows Plans to be dragged into Gantt Charts (name tentative) to create a configurable Activity View

- Clip/Unclip activity names by editing domainObject property

* feat: replace Plan if another is dragged in

- SImilar to Gauges or Scatter Plots, launch a confirmation dialog to replace the existing Plan with another, if another Plan is dragged into the chart.

* test: fix tests, add basic tests for gantt

* tes(e2e): fix plan test

* docs: add TODO

* refactor: clean up more string literals

* style: remove `rx`, increase min width

- round widths to nearest integer

* refactor: extract timeline creation logic

- extracts the logic for creating the timeline into its own component, `ActivityTimeline.vue`. This will save us a lot of re-renders, as we were manually creating elements / clearing them on each tick

* style: fix text y-pos and don't round

* fix: make activities clickable again

* docs: add copyright docs

* feat: swimlane visibility

- configure plan view from inspector

fix: update plans when file changes

- fix gantt chart display in time strips

- code cleanup

* fix: gantt chart embed in time strip

* remove viewBox for now

* fix: make `clipPath` ids more unique

* refactor: more code cleanup

* refactor: more code cleanup

* test: fix existing Plan unit tests

* refactor: rename variables

* fix: respond to code review comments

- Move config manipulation to PlanViewConfiguration.js/.vue

- Variable renames, code refactoring

* fix: unique, reproducible clipPathIds

* fix: only mutate swimlaneVisibility once on init

* fix: really make clipPathId unique this time

* refactor: use default config

* Closes #6113
- Refined CSS class naming and application.
- Set cursor to pointer for Activity elements.
- Added <title> node to Activity elements.
- Styling for selected Activities.
- Better Inspector tab name.

* fix: make Plan creatability configurable and false by default

* test: fix existing tests and add a couple new ones

* Closes #6113
- Now uses SVG <symbol> instead of rect within Activity element.
- Passes in `rowHeight` as a prop from Plan.vue.
- SWIMLANE_PADDING const added and used to create margin at top and bottom
edges of swimlanes.
- Refined styling for selected activities.
- New `$colorGanttSelectedBorder` theme constant.
- Smoke tested in Espresso and Snow themes.

* fix: default swimlaneWidth to clientWidth

* test: fix test

* feat: display selected activity name as header

* fix: remove redundant listener

* refactor: move `examplePlans.js` into `test-data/`

* docs: remove copyright header

* refactor: move `helper.js` into `helper/`

* refactor: `helper.js` -> `planningUtils.js`

* fix: update pathing

* test: add tests for gantt/plan

- add visual tests for gantt / plan

- add test for clicking a single activity and verifying its contents in the inspector

---------

Co-authored-by: Charles Hacskaylo <charlesh88@gmail.com>
This commit is contained in:
Jesse Mazzella 2023-03-16 10:34:31 -07:00 committed by GitHub
parent 0b3e0e7efd
commit ff3a20e446
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
40 changed files with 2847 additions and 857 deletions

View File

@ -159,24 +159,26 @@ async function expandTreePaneItemByName(page, name) {
* @returns {Promise<CreatedObjectInfo>} An object containing information about the newly created domain object.
*/
async function createPlanFromJSON(page, { name, json, parent = 'mine' }) {
if (!name) {
name = `Plan:${genUuid()}`;
}
const parentUrl = await getHashUrlToDomainObject(page, parent);
// Navigate to the parent object. This is necessary to create the object
// in the correct location, such as a folder, layout, or plot.
await page.goto(`${parentUrl}?hideTree=true`);
//Click the Create button
// Click the Create button
await page.click('button:has-text("Create")');
// Click 'Plan' menu option
await page.click(`li:text("Plan")`);
// Modify the name input field of the domain object to accept 'name'
if (name) {
const nameInput = page.locator('form[name="mctForm"] .first input[type="text"]');
await nameInput.fill("");
await nameInput.fill(name);
}
const nameInput = page.locator('form[name="mctForm"] .first input[type="text"]');
await nameInput.fill("");
await nameInput.fill(name);
// Upload buffer from memory
await page.locator('input#fileElem').setInputFiles({
@ -194,7 +196,7 @@ async function createPlanFromJSON(page, { name, json, parent = 'mine' }) {
]);
// Wait until the URL is updated
await page.waitForURL(`**/mine/*`);
await page.waitForURL(`**/${parent}/*`);
const uuid = await getFocusedObjectUuid(page);
const objectUrl = await getHashUrlToDomainObject(page, uuid);

View File

@ -0,0 +1,92 @@
/*****************************************************************************
* 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.
*****************************************************************************/
import { expect } from '../pluginFixtures';
/**
* Asserts that the number of activities in the plan view matches the number of
* activities in the plan data within the specified time bounds. Performs an assertion
* for each activity in the plan data per group, using the earliest activity's
* start time as the start bound and the current activity's end time as the end bound.
* @param {import('@playwright/test').Page} page the page
* @param {object} plan The raw plan json to assert against
* @param {string} objectUrl The URL of the object to assert against (plan or gantt chart)
*/
export async function assertPlanActivities(page, plan, objectUrl) {
const groups = Object.keys(plan);
for (const group of groups) {
for (let i = 0; i < plan[group].length; i++) {
// Set the startBound to the start time of the first activity in the group
const startBound = plan[group][0].start;
// Set the endBound to the end time of the current activity
let endBound = plan[group][i].end;
if (endBound === startBound) {
// Prevent oddities with setting start and end bound equal
// via URL params
endBound += 1;
}
// Switch to fixed time mode with all plan events within the bounds
await page.goto(`${objectUrl}?tc.mode=fixed&tc.startBound=${startBound}&tc.endBound=${endBound}&tc.timeSystem=utc&view=plan.view`);
// Assert that the number of activities in the plan view matches the number of
// activities in the plan data within the specified time bounds
const eventCount = await page.locator('.activity-bounds').count();
expect(eventCount).toEqual(Object.values(plan)
.flat()
.filter(event =>
activitiesWithinTimeBounds(event.start, event.end, startBound, endBound)).length);
}
}
}
/**
* Returns true if the activities time bounds overlap, false otherwise.
* @param {number} start1 the start time of the first activity
* @param {number} end1 the end time of the first activity
* @param {number} start2 the start time of the second activity
* @param {number} end2 the end time of the second activity
* @returns {boolean} true if the activities overlap, false otherwise
*/
function activitiesWithinTimeBounds(start1, end1, start2, end2) {
return (start1 >= start2 && start1 <= end2)
|| (end1 >= start2 && end1 <= end2)
|| (start2 >= start1 && start2 <= end1)
|| (end2 >= start1 && end2 <= end1);
}
/**
* Navigate to the plan view, switch to fixed time mode,
* and set the bounds to span all activities.
* @param {import('@playwright/test').Page} page
* @param {object} planJson
* @param {string} planObjectUrl
*/
export async function setBoundsToSpanAllActivities(page, planJson, planObjectUrl) {
const activities = Object.values(planJson).flat();
// Get the earliest start value
const start = Math.min(...activities.map(activity => activity.start));
// Get the latest end value
const end = Math.max(...activities.map(activity => activity.end));
// Set the start and end bounds to the earliest start and latest end
await page.goto(`${planObjectUrl}?tc.mode=fixed&tc.startBound=${start}&tc.endBound=${end}&tc.timeSystem=utc&view=plan.view`);
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,44 @@
{
"Group 1": [
{
"name": "Past event 1",
"start": 1660320408000,
"end": 1660343797000,
"type": "Group 1",
"color": "orange",
"textColor": "white"
},
{
"name": "Past event 2",
"start": 1660406808000,
"end": 1660429160000,
"type": "Group 1",
"color": "orange",
"textColor": "white"
},
{
"name": "Past event 3",
"start": 1660493208000,
"end": 1660503981000,
"type": "Group 1",
"color": "orange",
"textColor": "white"
},
{
"name": "Past event 4",
"start": 1660579608000,
"end": 1660624108000,
"type": "Group 1",
"color": "orange",
"textColor": "white"
},
{
"name": "Past event 5",
"start": 1660666008000,
"end": 1660681529000,
"type": "Group 1",
"color": "orange",
"textColor": "white"
}
]
}

View File

@ -0,0 +1,38 @@
{
"Group 1": [
{
"name": "Group 1 event 1",
"start": 1650320408000,
"end": 1660343797000,
"type": "Group 1",
"color": "orange",
"textColor": "white"
},
{
"name": "Group 1 event 2",
"start": 1660005808000,
"end": 1660429160000,
"type": "Group 1",
"color": "yellow",
"textColor": "white"
}
],
"Group 2": [
{
"name": "Group 2 event 1",
"start": 1660320408000,
"end": 1660420408000,
"type": "Group 2",
"color": "green",
"textColor": "white"
},
{
"name": "Group 2 event 2",
"start": 1660406808000,
"end": 1690429160000,
"type": "Group 2",
"color": "blue",
"textColor": "white"
}
]
}

View File

@ -0,0 +1,85 @@
/*****************************************************************************
* 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 { createPlanFromJSON, createDomainObjectWithDefaults, selectInspectorTab } = require('../../../appActions');
const testPlan1 = require('../../../test-data/examplePlans/ExamplePlan_Small1.json');
const testPlan2 = require('../../../test-data/examplePlans/ExamplePlan_Small2.json');
const { assertPlanActivities, setBoundsToSpanAllActivities } = require('../../../helper/planningUtils');
const { getPreciseDuration } = require('../../../../src/utils/duration');
test.describe("Gantt Chart", () => {
let ganttChart;
test.beforeEach(async ({ page }) => {
await page.goto('./', { waitUntil: 'networkidle' });
ganttChart = await createDomainObjectWithDefaults(page, {
type: 'Gantt Chart'
});
await createPlanFromJSON(page, {
json: testPlan1,
parent: ganttChart.uuid
});
});
test("Displays all plan events", async ({ page }) => {
await page.goto(ganttChart.url);
await assertPlanActivities(page, testPlan1, ganttChart.url);
});
test("Replaces a plan with a new plan", async ({ page }) => {
await assertPlanActivities(page, testPlan1, ganttChart.url);
await createPlanFromJSON(page, {
json: testPlan2,
parent: ganttChart.uuid
});
const replaceModal = page.getByRole('dialog').filter({ hasText: "This action will replace the current Plan. Do you want to continue?" });
await expect(replaceModal).toBeVisible();
await page.getByRole('button', { name: 'OK' }).click();
await assertPlanActivities(page, testPlan2, ganttChart.url);
});
test("Can select a single activity and display its details in the inspector", async ({ page }) => {
test.slow();
await page.goto(ganttChart.url);
await setBoundsToSpanAllActivities(page, testPlan1, ganttChart.url);
const activities = Object.values(testPlan1).flat();
const activity = activities[0];
await page.locator('g').filter({ hasText: new RegExp(activity.name) }).click();
await selectInspectorTab(page, 'Activity');
const startDateTime = await page.locator('.c-inspect-properties__label:has-text("Start DateTime")+.c-inspect-properties__value').innerText();
const endDateTime = await page.locator('.c-inspect-properties__label:has-text("End DateTime")+.c-inspect-properties__value').innerText();
const duration = await page.locator('.c-inspect-properties__label:has-text("duration")+.c-inspect-properties__value').innerText();
const expectedStartDate = new Date(activity.start).toISOString();
const actualStartDate = new Date(startDateTime).toISOString();
const expectedEndDate = new Date(activity.end).toISOString();
const actualEndDate = new Date(endDateTime).toISOString();
const expectedDuration = getPreciseDuration(activity.end - activity.start);
const actualDuration = duration;
expect(expectedStartDate).toEqual(actualStartDate);
expect(expectedEndDate).toEqual(actualEndDate);
expect(expectedDuration).toEqual(actualDuration);
});
});

View File

@ -19,69 +19,21 @@
* 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 { test } = require('../../../pluginFixtures');
const { 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"
}
]
};
const testPlan1 = require('../../../test-data/examplePlans/ExamplePlan_Small1.json');
const { assertPlanActivities } = require('../../../helper/planningUtils');
test.describe("Plan", () => {
test("Create a Plan and display all plan events @unstable", async ({ page }) => {
let plan;
test.beforeEach(async ({ page }) => {
await page.goto('./', { waitUntil: 'networkidle' });
const plan = await createPlanFromJSON(page, {
name: 'Test Plan',
json: testPlan
plan = await createPlanFromJSON(page, {
json: testPlan1
});
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(`${plan.url}?tc.mode=fixed&tc.startBound=${startBound}&tc.endBound=${endBound}&tc.timeSystem=utc&view=plan.view`);
const eventCount = await page.locator('.activity-bounds').count();
expect(eventCount).toEqual(testPlan.TEST_GROUP.length);
test("Displays all plan events", async ({ page }) => {
await assertPlanActivities(page, testPlan1, plan.url);
});
});

View File

@ -0,0 +1,52 @@
/*****************************************************************************
* 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 } = require('../../../pluginFixtures');
const { setBoundsToSpanAllActivities } = require('../../../helper/planningUtils');
const { createDomainObjectWithDefaults, createPlanFromJSON } = require('../../../appActions');
const percySnapshot = require('@percy/playwright');
const examplePlanLarge = require('../../../test-data/ExamplePlan_Large.json');
test.describe('Visual - Planning', () => {
test.beforeEach(async ({ page }) => {
await page.goto('./', { waitUntil: 'networkidle' });
});
test('Plan View', async ({ page, theme }) => {
const plan = await createPlanFromJSON(page, {
json: examplePlanLarge
});
await setBoundsToSpanAllActivities(page, examplePlanLarge, plan.url);
await percySnapshot(page, `Plan View (theme: ${theme})`);
});
test('Gantt Chart View', async ({ page, theme }) => {
const ganttChart = await createDomainObjectWithDefaults(page, {
type: 'Gantt Chart'
});
await createPlanFromJSON(page, {
json: examplePlanLarge,
parent: ganttChart.uuid
});
await setBoundsToSpanAllActivities(page, examplePlanLarge, ganttChart.url);
await percySnapshot(page, `Gantt Chart View (theme: ${theme})`);
});
});

View File

@ -84,7 +84,9 @@
openmct.install(openmct.plugins.Espresso());
openmct.install(openmct.plugins.MyItems());
openmct.install(openmct.plugins.PlanLayout());
openmct.install(openmct.plugins.PlanLayout({
creatable: true
}));
openmct.install(openmct.plugins.Timeline());
openmct.install(openmct.plugins.Hyperlink());
openmct.install(openmct.plugins.UTCTimeSystem());

View File

@ -46,7 +46,7 @@ export default class Editor extends EventEmitter {
}
/**
* @returns true if the application is in edit mode, false otherwise.
* @returns {boolean} true if the application is in edit mode, false otherwise.
*/
isEditing() {
return this.editing;

View File

@ -71,7 +71,7 @@ function (
StatusAPI: StatusAPI.default,
TelemetryAPI: TelemetryAPI,
TimeAPI: TimeAPI.default,
TypeRegistry: TypeRegistry,
TypeRegistry: TypeRegistry.default,
UserAPI: UserAPI.default,
AnnotationAPI: AnnotationAPI.default
};

View File

@ -62,6 +62,8 @@ import InMemorySearchProvider from './InMemorySearchProvider';
* @property {Identifier[]} [composition] if
* present, this will be used by the default composition provider
* to load domain objects
* @property {Object.<string, any>} [configuration] A key-value map containing configuration
* settings for this domain object.
* @memberof module:openmct.ObjectAPI~
*/

View File

@ -20,63 +20,25 @@
* at runtime from the About dialog for additional information.
*****************************************************************************/
define(function () {
/**
* A Type describes a kind of domain object that may appear or be
* created within Open MCT.
*
* @param {module:opemct.TypeRegistry~TypeDefinition} definition
* @class Type
* @memberof module:openmct
*/
function Type(definition) {
/**
* A Type describes a kind of domain object that may appear or be
* created within Open MCT.
*
* @param {module:opemct.TypeRegistry~TypeDefinition} definition
* @class Type
* @memberof module:openmct
*/
export default class Type {
constructor(definition) {
this.definition = definition;
if (definition.key) {
this.key = definition.key;
}
}
/**
* Check if a domain object is an instance of this type.
* @param domainObject
* @returns {boolean} true if the domain object is of this type
* @memberof module:openmct.Type#
* @method check
*/
Type.prototype.check = function (domainObject) {
// Depends on assignment from MCT.
return domainObject.type === this.key;
};
/**
* Get a definition for this type that can be registered using the
* legacy bundle format.
* @private
*/
Type.prototype.toLegacyDefinition = function () {
const def = {};
def.name = this.definition.name;
def.cssClass = this.definition.cssClass;
def.description = this.definition.description;
def.properties = this.definition.form;
if (this.definition.initialize) {
def.model = {};
this.definition.initialize(def.model);
}
if (this.definition.creatable) {
def.features = ['creation'];
}
return def;
};
/**
* Create a type definition from a legacy definition.
*/
Type.definitionFromLegacyDefinition = function (legacyDefinition) {
static definitionFromLegacyDefinition(legacyDefinition) {
let definition = {};
definition.name = legacyDefinition.name;
definition.cssClass = legacyDefinition.cssClass;
@ -121,7 +83,39 @@ define(function () {
}
return definition;
};
}
/**
* Check if a domain object is an instance of this type.
* @param domainObject
* @returns {boolean} true if the domain object is of this type
* @memberof module:openmct.Type#
* @method check
*/
check(domainObject) {
// Depends on assignment from MCT.
return domainObject.type === this.key;
}
/**
* Get a definition for this type that can be registered using the
* legacy bundle format.
* @private
*/
toLegacyDefinition() {
const def = {};
def.name = this.definition.name;
def.cssClass = this.definition.cssClass;
def.description = this.definition.description;
def.properties = this.definition.form;
return Type;
});
if (this.definition.initialize) {
def.model = {};
this.definition.initialize(def.model);
}
if (this.definition.creatable) {
def.features = ['creation'];
}
return def;
}
}

View File

@ -19,35 +19,36 @@
* this source code distribution or the Licensing information page available
* at runtime from the About dialog for additional information.
*****************************************************************************/
define(['./Type'], function (Type) {
const UNKNOWN_TYPE = new Type({
key: "unknown",
name: "Unknown Type",
cssClass: "icon-object-unknown"
});
import Type from './Type';
/**
* @typedef TypeDefinition
* @memberof module:openmct.TypeRegistry~
* @property {string} label the name for this type of object
* @property {string} description a longer-form description of this type
* @property {function (object)} [initialize] a function which initializes
* the model for new domain objects of this type
* @property {boolean} [creatable] true if users should be allowed to
* create this type (default: false)
* @property {string} [cssClass] the CSS class to apply for icons
*/
const UNKNOWN_TYPE = new Type({
key: "unknown",
name: "Unknown Type",
cssClass: "icon-object-unknown"
});
/**
* A TypeRegistry maintains the definitions for different types
* that domain objects may have.
* @interface TypeRegistry
* @memberof module:openmct
*/
function TypeRegistry() {
/**
* @typedef TypeDefinition
* @memberof module:openmct.TypeRegistry~
* @property {string} label the name for this type of object
* @property {string} description a longer-form description of this type
* @property {function (object)} [initialize] a function which initializes
* the model for new domain objects of this type
* @property {boolean} [creatable] true if users should be allowed to
* create this type (default: false)
* @property {string} [cssClass] the CSS class to apply for icons
*/
/**
* A TypeRegistry maintains the definitions for different types
* that domain objects may have.
* @interface TypeRegistry
* @memberof module:openmct
*/
export default class TypeRegistry {
constructor() {
this.types = {};
}
/**
* Register a new object type.
*
@ -56,17 +57,16 @@ define(['./Type'], function (Type) {
* @method addType
* @memberof module:openmct.TypeRegistry#
*/
TypeRegistry.prototype.addType = function (typeKey, typeDef) {
addType(typeKey, typeDef) {
this.standardizeType(typeDef);
this.types[typeKey] = new Type(typeDef);
};
}
/**
* Takes a typeDef, standardizes it, and logs warnings about unsupported
* usage.
* @private
*/
TypeRegistry.prototype.standardizeType = function (typeDef) {
standardizeType(typeDef) {
if (Object.prototype.hasOwnProperty.call(typeDef, 'label')) {
if (!typeDef.name) {
typeDef.name = typeDef.label;
@ -74,18 +74,16 @@ define(['./Type'], function (Type) {
delete typeDef.label;
}
};
}
/**
* List keys for all registered types.
* @method listKeys
* @memberof module:openmct.TypeRegistry#
* @returns {string[]} all registered type keys
*/
TypeRegistry.prototype.listKeys = function () {
listKeys() {
return Object.keys(this.types);
};
}
/**
* Retrieve a registered type by its key.
* @method get
@ -93,18 +91,15 @@ define(['./Type'], function (Type) {
* @memberof module:openmct.TypeRegistry#
* @returns {module:openmct.Type} the registered type
*/
TypeRegistry.prototype.get = function (typeKey) {
get(typeKey) {
return this.types[typeKey] || UNKNOWN_TYPE;
};
TypeRegistry.prototype.importLegacyTypes = function (types) {
}
importLegacyTypes(types) {
types.filter((t) => this.get(t.key) === UNKNOWN_TYPE)
.forEach((type) => {
let def = Type.definitionFromLegacyDefinition(type);
this.addType(type.key, def);
});
};
return TypeRegistry;
});
}
}

View File

@ -20,36 +20,36 @@
* at runtime from the About dialog for additional information.
*****************************************************************************/
define(['./TypeRegistry', './Type'], function (TypeRegistry, Type) {
describe('The Type API', function () {
let typeRegistryInstance;
import TypeRegistry from './TypeRegistry';
beforeEach(function () {
typeRegistryInstance = new TypeRegistry ();
typeRegistryInstance.addType('testType', {
name: 'Test Type',
description: 'This is a test type.',
creatable: true
});
});
describe('The Type API', function () {
let typeRegistryInstance;
it('types can be standardized', function () {
typeRegistryInstance.addType('standardizationTestType', {
label: 'Test Type',
description: 'This is a test type.',
creatable: true
});
typeRegistryInstance.standardizeType(typeRegistryInstance.types.standardizationTestType);
expect(typeRegistryInstance.get('standardizationTestType').definition.label).toBeUndefined();
expect(typeRegistryInstance.get('standardizationTestType').definition.name).toBe('Test Type');
});
it('new types are registered successfully and can be retrieved', function () {
expect(typeRegistryInstance.get('testType').definition.name).toBe('Test Type');
});
it('type registry contains new keys', function () {
expect(typeRegistryInstance.listKeys ()).toContain('testType');
beforeEach(function () {
typeRegistryInstance = new TypeRegistry ();
typeRegistryInstance.addType('testType', {
name: 'Test Type',
description: 'This is a test type.',
creatable: true
});
});
it('types can be standardized', function () {
typeRegistryInstance.addType('standardizationTestType', {
label: 'Test Type',
description: 'This is a test type.',
creatable: true
});
typeRegistryInstance.standardizeType(typeRegistryInstance.types.standardizationTestType);
expect(typeRegistryInstance.get('standardizationTestType').definition.label).toBeUndefined();
expect(typeRegistryInstance.get('standardizationTestType').definition.name).toBe('Test Type');
});
it('new types are registered successfully and can be retrieved', function () {
expect(typeRegistryInstance.get('testType').definition.name).toBe('Test Type');
});
it('type registry contains new keys', function () {
expect(typeRegistryInstance.listKeys ()).toContain('testType');
});
});

View File

@ -0,0 +1,35 @@
/*****************************************************************************
* 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 ALLOWED_TYPES = [
'plan'
];
export default function ganttChartCompositionPolicy(openmct) {
return function (parent, child) {
if (parent.type === 'gantt-chart') {
return ALLOWED_TYPES.includes(child.type);
}
return true;
};
}

View File

@ -1,591 +0,0 @@
<!--
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>
<div
ref="plan"
class="c-plan c-timeline-holder"
>
<template v-if="viewBounds && !options.compact">
<swim-lane>
<template slot="label">{{ timeSystem.name }}</template>
<timeline-axis
slot="object"
:bounds="viewBounds"
:time-system="timeSystem"
:content-height="height"
:rendering-engine="renderingEngine"
/>
</swim-lane>
</template>
<div
ref="planHolder"
class="c-plan__contents u-contents"
>
</div>
</div>
</template>
<script>
import * as d3Scale from 'd3-scale';
import TimelineAxis from "../../ui/components/TimeSystemAxis.vue";
import SwimLane from "@/ui/components/swim-lane/SwimLane.vue";
import { getValidatedData } from "./util";
import Vue from "vue";
const PADDING = 1;
const OUTER_TEXT_PADDING = 12;
const INNER_TEXT_PADDING = 17;
const TEXT_LEFT_PADDING = 5;
const ROW_PADDING = 12;
const RESIZE_POLL_INTERVAL = 200;
const ROW_HEIGHT = 25;
const LINE_HEIGHT = 12;
const MAX_TEXT_WIDTH = 300;
const EDGE_ROUNDING = 5;
const DEFAULT_COLOR = '#cc9922';
export default {
components: {
TimelineAxis,
SwimLane
},
inject: ['openmct', 'domainObject', 'path'],
props: {
options: {
type: Object,
default() {
return {
compact: false
};
}
},
renderingEngine: {
type: String,
default() {
return 'svg';
}
}
},
data() {
return {
viewBounds: undefined,
timeSystem: undefined,
height: 0
};
},
mounted() {
this.getPlanData(this.domainObject);
this.canvas = this.$refs.plan.appendChild(document.createElement('canvas'));
this.canvas.height = 0;
this.canvasContext = this.canvas.getContext('2d');
this.setDimensions();
this.setTimeContext();
this.resizeTimer = setInterval(this.resize, RESIZE_POLL_INTERVAL);
this.unlisten = this.openmct.objects.observe(this.domainObject, '*', this.observeForChanges);
this.removeStatusListener = this.openmct.status.observe(this.domainObject.identifier, this.setStatus);
this.status = this.openmct.status.get(this.domainObject.identifier);
},
beforeDestroy() {
clearInterval(this.resizeTimer);
this.stopFollowingTimeContext();
if (this.unlisten) {
this.unlisten();
}
if (this.removeStatusListener) {
this.removeStatusListener();
}
},
methods: {
setTimeContext() {
this.stopFollowingTimeContext();
this.timeContext = this.openmct.time.getContextForView(this.path);
this.followTimeContext();
},
followTimeContext() {
this.updateViewBounds(this.timeContext.bounds());
this.timeContext.on("timeSystem", this.setScaleAndPlotActivities);
this.timeContext.on("bounds", this.updateViewBounds);
this.timeContext.on("clock", this.updateBounds);
},
stopFollowingTimeContext() {
if (this.timeContext) {
this.timeContext.off("timeSystem", this.setScaleAndPlotActivities);
this.timeContext.off("bounds", this.updateViewBounds);
this.timeContext.off("clock", this.updateBounds);
}
},
observeForChanges(mutatedObject) {
this.getPlanData(mutatedObject);
this.setScaleAndPlotActivities();
},
resize() {
let clientWidth = this.getClientWidth();
let clientHeight = this.getClientHeight();
if (clientWidth !== this.width) {
this.setDimensions();
this.updateViewBounds();
}
if (clientHeight !== this.height) {
this.setDimensions();
}
},
getClientWidth() {
let clientWidth = this.$refs.plan.clientWidth;
if (!clientWidth) {
//this is a hack - need a better way to find the parent of this component
let parent = this.openmct.layout.$refs.browseObject.$el;
if (parent) {
clientWidth = parent.getBoundingClientRect().width;
}
}
return clientWidth - 200;
},
getClientHeight() {
let clientHeight = this.$refs.plan.clientHeight;
if (!clientHeight) {
//this is a hack - need a better way to find the parent of this component
let parent = this.openmct.layout.$refs.browseObject.$el;
if (parent) {
clientHeight = parent.getBoundingClientRect().height;
}
}
return clientHeight;
},
getPlanData(domainObject) {
this.planData = getValidatedData(domainObject);
},
updateBounds(clock) {
if (clock === undefined) {
this.viewBounds = Object.create(this.timeContext.bounds());
}
},
updateViewBounds(bounds) {
if (bounds) {
this.viewBounds = Object.create(bounds);
}
if (this.timeSystem === undefined) {
this.timeSystem = this.openmct.time.timeSystem();
}
this.setScaleAndPlotActivities();
},
setScaleAndPlotActivities(timeSystem) {
if (timeSystem !== undefined) {
this.timeSystem = timeSystem;
}
this.setScale(this.timeSystem);
this.clearPreviousActivities();
if (this.xScale) {
this.calculatePlanLayout();
this.drawPlan();
}
},
clearPreviousActivities() {
let activities = this.$el.querySelectorAll(".c-plan__contents > div");
activities.forEach(activity => activity.remove());
},
setDimensions() {
this.width = this.getClientWidth();
this.height = this.getClientHeight();
},
setScale(timeSystem) {
if (!this.width) {
return;
}
if (timeSystem === undefined) {
timeSystem = this.openmct.time.timeSystem();
}
if (timeSystem.isUTCBased) {
this.xScale = d3Scale.scaleUtc();
this.xScale.domain(
[new Date(this.viewBounds.start), new Date(this.viewBounds.end)]
);
} else {
this.xScale = d3Scale.scaleLinear();
this.xScale.domain(
[this.viewBounds.start, this.viewBounds.end]
);
}
this.xScale.range([PADDING, this.width - PADDING * 2]);
},
isActivityInBounds(activity) {
return (activity.start < this.viewBounds.end) && (activity.end > this.viewBounds.start);
},
getTextWidth(name) {
let metrics = this.canvasContext.measureText(name);
return parseInt(metrics.width, 10);
},
sortFn(a, b) {
const numA = parseInt(a, 10);
const numB = parseInt(b, 10);
if (numA > numB) {
return 1;
}
if (numA < numB) {
return -1;
}
return 0;
},
// Get the row where the next activity will land.
getRowForActivity(rectX, width, activitiesByRow) {
let currentRow;
let sortedActivityRows = Object.keys(activitiesByRow).sort(this.sortFn);
function getOverlap(rects) {
return rects.every(rect => {
const { start, end } = rect;
const calculatedEnd = rectX + width;
const hasOverlap = (rectX >= start && rectX <= end) || (calculatedEnd >= start && calculatedEnd <= end) || (rectX <= start && calculatedEnd >= end);
return !hasOverlap;
});
}
for (let i = 0; i < sortedActivityRows.length; i++) {
let row = sortedActivityRows[i];
if (getOverlap(activitiesByRow[row])) {
currentRow = row;
break;
}
}
if (currentRow === undefined && sortedActivityRows.length) {
let row = parseInt(sortedActivityRows[sortedActivityRows.length - 1], 10);
currentRow = row + ROW_HEIGHT + ROW_PADDING;
}
return (currentRow || 0);
},
calculatePlanLayout() {
let groups = Object.keys(this.planData);
this.groupActivities = {};
groups.forEach((key, index) => {
let activitiesByRow = {};
let currentRow = 0;
let activities = this.planData[key];
activities.forEach((activity) => {
if (this.isActivityInBounds(activity)) {
const currentStart = Math.max(this.viewBounds.start, activity.start);
const currentEnd = Math.min(this.viewBounds.end, activity.end);
const rectX = this.xScale(currentStart);
const rectY = this.xScale(currentEnd);
const rectWidth = rectY - rectX;
const activityNameWidth = this.getTextWidth(activity.name) + TEXT_LEFT_PADDING;
//TODO: Fix bug for SVG where the rectWidth is not proportional to the canvas measuredWidth of the text
const activityNameFitsRect = (rectWidth >= activityNameWidth);
const textStart = (activityNameFitsRect ? rectX : rectY) + TEXT_LEFT_PADDING;
const color = activity.color || DEFAULT_COLOR;
let textColor = '';
if (activity.textColor) {
textColor = activity.textColor;
} else if (activityNameFitsRect) {
textColor = this.getContrastingColor(color);
}
let textLines = this.getActivityDisplayText(this.canvasContext, activity.name, activityNameFitsRect);
const textWidth = textStart + this.getTextWidth(textLines[0]) + TEXT_LEFT_PADDING;
if (activityNameFitsRect) {
currentRow = this.getRowForActivity(rectX, rectWidth, activitiesByRow);
} else {
currentRow = this.getRowForActivity(rectX, textWidth, activitiesByRow);
}
let textY = parseInt(currentRow, 10) + (activityNameFitsRect ? INNER_TEXT_PADDING : OUTER_TEXT_PADDING);
if (!activitiesByRow[currentRow]) {
activitiesByRow[currentRow] = [];
}
activitiesByRow[currentRow].push({
activity: {
color: color,
textColor: textColor,
name: activity.name,
exceeds: {
start: this.xScale(this.viewBounds.start) > this.xScale(activity.start),
end: this.xScale(this.viewBounds.end) < this.xScale(activity.end)
},
start: activity.start,
end: activity.end
},
textLines: textLines,
textStart: textStart,
textClass: activityNameFitsRect ? "" : "activity-label--outside-rect",
textY: textY,
start: rectX,
end: activityNameFitsRect ? rectY : textStart + textWidth,
rectWidth: rectWidth
});
}
});
this.groupActivities[key] = {
heading: key,
activitiesByRow
};
});
},
getActivityDisplayText(context, text, activityNameFitsRect) {
//TODO: If the activity start is less than viewBounds.start then the text should be cropped on the left/should be off-screen)
let words = text.split(' ');
let line = '';
let activityText = [];
let rows = 1;
for (let n = 0; (n < words.length) && (rows <= 2); n++) {
let testLine = line + words[n] + ' ';
let metrics = context.measureText(testLine);
let testWidth = metrics.width;
if (!activityNameFitsRect && (testWidth > MAX_TEXT_WIDTH && n > 0)) {
activityText.push(line);
line = words[n] + ' ';
testLine = line + words[n] + ' ';
rows = rows + 1;
}
line = testLine;
}
return activityText.length ? activityText : [line];
},
getGroupContainer(activityRows, heading) {
let svgHeight = 30;
let svgWidth = 200;
const rows = Object.keys(activityRows);
const isNested = this.options.isChildObject;
const status = isNested ? '' : this.status;
if (rows.length) {
const lastActivityRow = rows[rows.length - 1];
svgHeight = parseInt(lastActivityRow, 10) + ROW_HEIGHT;
svgWidth = this.width;
}
let component = new Vue({
components: {
SwimLane
},
provide: {
openmct: this.openmct
},
data() {
return {
heading,
isNested,
status,
height: svgHeight,
width: svgWidth
};
},
template: `<swim-lane :is-nested="isNested" :status="status"><template slot="label">{{heading}}</template><template slot="object"><svg :height="height" :width="width"></svg></template></swim-lane>`
});
this.$refs.planHolder.appendChild(component.$mount().$el);
let groupLabel = component.$el.querySelector('div:nth-child(1)');
let groupSVG = component.$el.querySelector('svg');
return {
groupLabel,
groupSVG
};
},
drawPlan() {
Object.keys(this.groupActivities).forEach((group, index) => {
const activitiesByRow = this.groupActivities[group].activitiesByRow;
const heading = this.groupActivities[group].heading;
const groupElements = this.getGroupContainer(activitiesByRow, heading);
let groupSVG = groupElements.groupSVG;
let activityRows = Object.keys(activitiesByRow);
if (activityRows.length <= 0) {
this.plotNoItems(groupSVG);
}
activityRows.forEach((row) => {
const items = activitiesByRow[row];
items.forEach(item => {
this.plotActivity(item, parseInt(row, 10), groupSVG);
});
});
});
},
plotNoItems(svgElement) {
let textElement = document.createElementNS('http://www.w3.org/2000/svg', 'text');
this.setNSAttributesForElement(textElement, {
x: "10",
y: "20",
class: "activity-label--outside-rect"
});
textElement.innerHTML = 'No activities within timeframe';
svgElement.appendChild(textElement);
},
setNSAttributesForElement(element, attributes) {
Object.keys(attributes).forEach((key) => {
element.setAttributeNS(null, key, attributes[key]);
});
},
getNSAttributesForElement(element, attribute) {
return element.getAttributeNS(null, attribute);
},
// Experimental for now - unused
addForeignElement(svgElement, label, x, y) {
let foreign = document.createElementNS('http://www.w3.org/2000/svg', "foreignObject");
this.setNSAttributesForElement(foreign, {
width: String(MAX_TEXT_WIDTH),
height: String(LINE_HEIGHT * 2),
x: x,
y: y
});
let textEl = document.createElement('div');
let textNode = document.createTextNode(label);
textEl.appendChild(textNode);
foreign.appendChild(textEl);
svgElement.appendChild(foreign);
},
plotActivity(item, row, svgElement) {
const activity = item.activity;
let width = item.rectWidth;
let rectElement = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
if (item.activity.exceeds.start) {
width = width + EDGE_ROUNDING;
}
if (item.activity.exceeds.end) {
width = width + EDGE_ROUNDING;
}
width = Math.max(width, 1); // Set width to a minimum of 1
// rx: don't round corners if the width of the rect is smaller than the rounding radius
this.setNSAttributesForElement(rectElement, {
class: 'activity-bounds',
x: item.activity.exceeds.start ? item.start - EDGE_ROUNDING : item.start,
y: row,
rx: (width < EDGE_ROUNDING * 2) ? 0 : EDGE_ROUNDING,
width: width,
height: String(ROW_HEIGHT),
fill: activity.color
});
rectElement.addEventListener('click', (event) => {
this.setSelectionForActivity(event.currentTarget, activity, event.metaKey);
});
svgElement.appendChild(rectElement);
item.textLines.forEach((line, index) => {
let textElement = document.createElementNS('http://www.w3.org/2000/svg', 'text');
this.setNSAttributesForElement(textElement, {
class: `activity-label ${item.textClass}`,
x: item.textStart,
y: item.textY + (index * LINE_HEIGHT),
fill: activity.textColor
});
const textNode = document.createTextNode(line);
textElement.appendChild(textNode);
textElement.addEventListener('click', (event) => {
this.setSelectionForActivity(event.currentTarget, activity, event.metaKey);
});
svgElement.appendChild(textElement);
});
// this.addForeignElement(svgElement, activity.name, item.textStart, item.textY - LINE_HEIGHT);
},
cutHex(h, start, end) {
const hStr = (h.charAt(0) === '#') ? h.substring(1, 7) : h;
return parseInt(hStr.substring(start, end), 16);
},
getContrastingColor(hexColor) {
// https://codepen.io/davidhalford/pen/ywEva/
// TODO: move this into a general utility function?
const cThreshold = 130;
if (hexColor.indexOf('#') === -1) {
// We weren't given a hex color
return "#ff0000";
}
const hR = this.cutHex(hexColor, 0, 2);
const hG = this.cutHex(hexColor, 2, 4);
const hB = this.cutHex(hexColor, 4, 6);
const cBrightness = ((hR * 299) + (hG * 587) + (hB * 114)) / 1000;
return cBrightness > cThreshold ? "#000000" : "#ffffff";
},
setSelectionForActivity(element, activity, multiSelect) {
this.openmct.selection.select([{
element: element,
context: {
type: 'activity',
activity: activity
}
}, {
element: this.openmct.layout.$refs.browseObject.$el,
context: {
item: this.domainObject,
supportsMultiSelect: true
}
}], multiSelect);
event.stopPropagation();
},
setStatus(status) {
this.status = status;
if (this.xScale) {
this.drawPlan();
}
}
}
};
</script>

View File

@ -0,0 +1,110 @@
/*****************************************************************************
* 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.
*****************************************************************************/
import EventEmitter from 'EventEmitter';
export const DEFAULT_CONFIGURATION = {
clipActivityNames: false,
swimlaneVisibility: {}
};
export default class PlanViewConfiguration extends EventEmitter {
constructor(domainObject, openmct) {
super();
this.domainObject = domainObject;
this.openmct = openmct;
this.configurationChanged = this.configurationChanged.bind(this);
this.unlistenFromMutation = openmct.objects.observe(domainObject, 'configuration', this.configurationChanged);
}
/**
* @returns {Object.<string, any>}
*/
getConfiguration() {
const configuration = this.domainObject.configuration ?? {};
for (const configKey of Object.keys(DEFAULT_CONFIGURATION)) {
configuration[configKey] = configuration[configKey] ?? DEFAULT_CONFIGURATION[configKey];
}
return configuration;
}
#updateConfiguration(configuration) {
this.openmct.objects.mutate(this.domainObject, 'configuration', configuration);
}
/**
* @param {string} swimlaneName
* @param {boolean} isVisible
*/
setSwimlaneVisibility(swimlaneName, isVisible) {
const configuration = this.getConfiguration();
const { swimlaneVisibility } = configuration;
swimlaneVisibility[swimlaneName] = isVisible;
this.#updateConfiguration(configuration);
}
resetSwimlaneVisibility() {
const configuration = this.getConfiguration();
const swimlaneVisibility = {};
configuration.swimlaneVisibility = swimlaneVisibility;
this.#updateConfiguration(configuration);
}
initializeSwimlaneVisibility(swimlaneNames) {
const configuration = this.getConfiguration();
const { swimlaneVisibility } = configuration;
let shouldMutate = false;
for (const swimlaneName of swimlaneNames) {
if (swimlaneVisibility[swimlaneName] === undefined) {
swimlaneVisibility[swimlaneName] = true;
shouldMutate = true;
}
}
if (shouldMutate) {
configuration.swimlaneVisibility = swimlaneVisibility;
this.#updateConfiguration(configuration);
}
}
/**
* @param {boolean} isEnabled
*/
setClipActivityNames(isEnabled) {
const configuration = this.getConfiguration();
configuration.clipActivityNames = isEnabled;
this.#updateConfiguration(configuration);
}
configurationChanged(configuration) {
if (configuration !== undefined) {
this.emit('change', configuration);
}
}
destroy() {
this.unlistenFromMutation();
}
}

View File

@ -20,7 +20,7 @@
* at runtime from the About dialog for additional information.
*****************************************************************************/
import Plan from './Plan.vue';
import Plan from './components/Plan.vue';
import Vue from 'vue';
export default function PlanViewProvider(openmct) {
@ -35,11 +35,11 @@ export default function PlanViewProvider(openmct) {
name: 'Plan',
cssClass: 'icon-plan',
canView(domainObject) {
return domainObject.type === 'plan';
return domainObject.type === 'plan' || domainObject.type === 'gantt-chart';
},
canEdit(domainObject) {
return false;
return domainObject.type === 'gantt-chart';
},
view: function (domainObject, objectPath) {

View File

@ -0,0 +1,187 @@
<!--
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>
<swim-lane
:is-nested="isNested"
:status="status"
>
<template slot="label">
{{ heading }}
</template>
<template slot="object">
<svg
:height="height"
:width="width"
>
<symbol
id="activity-bar-bg"
:height="rowHeight"
width="2"
preserveAspectRatio="none"
>
<rect
x="0"
y="0"
width="100%"
height="100%"
fill="currentColor"
/>
<line
x1="100%"
y1="0"
x2="100%"
y2="100%"
stroke="black"
stroke-width="1"
opacity="0.3"
transform="translate(-0.5, 0)"
/>
</symbol>
<template v-for="(activity, index) in activities">
<template v-if="clipActivityNames === true">
<clipPath
:id="activity.clipPathId"
:key="activity.clipPathId"
>
<rect
:x="activity.rectStart"
:y="activity.row"
:width="activity.rectWidth - 1"
:height="rowHeight"
/>
</clipPath>
</template>
<g
:key="`g-${activity.clipPathId}`"
class="c-plan__activity activity-bounds"
@click="setSelectionForActivity(activity, $event)"
>
<title>{{ activity.name }}</title>
<use
:key="`rect-${index}`"
href="#activity-bar-bg"
:x="activity.rectStart"
:y="activity.row"
:width="activity.rectWidth"
:height="rowHeight"
:class="activity.class"
:color="activity.color"
/>
<text
v-for="(textLine, textIndex) in activity.textLines"
:key="`text-${index}-${textIndex}`"
:class="`c-plan__activity-label ${activity.textClass}`"
:x="activity.textStart"
:y="activity.textY + (textIndex * lineHeight)"
:fill="activity.textColor"
:clip-path="clipActivityNames === true ? `url(#${activity.clipPathId})` : ''"
>
{{ textLine }}
</text>
</g>
</template>
<text
v-if="activities.length === 0"
x="10"
y="20"
class="c-plan__activity-label--outside-rect"
>
No activities within current timeframe
</text>
</svg>
</template>
</swim-lane>
</template>
<script>
import SwimLane from '@/ui/components/swim-lane/SwimLane.vue';
export default {
components: {
SwimLane
},
inject: ['openmct', 'domainObject'],
props: {
activities: {
type: Array,
required: true
},
clipActivityNames: {
type: Boolean,
default: false
},
heading: {
type: String,
required: true
},
height: {
type: Number,
default: 30
},
width: {
type: Number,
default: 200
},
isNested: {
type: Boolean,
default: false
},
status: {
type: String,
default() {
return '';
}
},
rowHeight: {
type: Number,
default: 22
}
},
data() {
return {
lineHeight: 10
};
},
methods: {
setSelectionForActivity(activity, event) {
const element = event.currentTarget;
const multiSelect = event.metaKey;
event.stopPropagation();
this.openmct.selection.select([{
element: element,
context: {
type: 'activity',
activity: activity
}
}, {
element: this.openmct.layout.$refs.browseObject.$el,
context: {
item: this.domainObject,
supportsMultiSelect: true
}
}], multiSelect);
}
}
};
</script>

View File

@ -0,0 +1,558 @@
* 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>
<div
ref="plan"
class="c-plan c-timeline-holder"
>
<template v-if="viewBounds && !options.compact">
<swim-lane>
<template slot="label">{{ timeSystem.name }}</template>
<timeline-axis
slot="object"
:bounds="viewBounds"
:time-system="timeSystem"
:content-height="height"
:rendering-engine="renderingEngine"
/>
</swim-lane>
</template>
<div
class="c-plan__contents u-contents"
>
<ActivityTimeline
v-for="(group, index) in visibleActivityGroups"
:key="`activityGroup-${group.heading}-${index}`"
:activities="group.activities"
:clip-activity-names="clipActivityNames"
:heading="group.heading"
:height="group.height"
:row-height="rowHeight"
:width="group.width"
:is-nested="options.isChildObject"
:status="status"
/>
</div>
</div>
</template>
<script>
import * as d3Scale from 'd3-scale';
import TimelineAxis from "../../../ui/components/TimeSystemAxis.vue";
import ActivityTimeline from "./ActivityTimeline.vue";
import SwimLane from "@/ui/components/swim-lane/SwimLane.vue";
import { getValidatedData, getContrastingColor } from "../util";
import PlanViewConfiguration from '../PlanViewConfiguration';
const PADDING = 1;
const OUTER_TEXT_PADDING = 12;
const INNER_TEXT_PADDING = 15;
const TEXT_LEFT_PADDING = 5;
const ROW_PADDING = 5;
const SWIMLANE_PADDING = 3;
const RESIZE_POLL_INTERVAL = 200;
const ROW_HEIGHT = 22;
const MAX_TEXT_WIDTH = 300;
const MIN_ACTIVITY_WIDTH = 2;
const DEFAULT_COLOR = '#999';
export default {
components: {
TimelineAxis,
SwimLane,
ActivityTimeline
},
inject: ['openmct', 'domainObject', 'path'],
props: {
options: {
type: Object,
default() {
return {
compact: false,
isChildObject: false
};
}
},
renderingEngine: {
type: String,
default() {
return 'svg';
}
}
},
data() {
return {
activityGroups: [],
viewBounds: null,
timeSystem: null,
planData: {},
swimlaneVisibility: {},
clipActivityNames: false,
height: 0,
rowHeight: ROW_HEIGHT
};
},
computed: {
visibleActivityGroups() {
if (this.domainObject.type === 'plan') {
return this.activityGroups;
} else {
return this.activityGroups.filter(group =>
this.swimlaneVisibility[group.heading] === true);
}
}
},
watch: {
clipActivityNames() {
this.setScaleAndGenerateActivities();
}
},
mounted() {
this.composition = this.openmct.composition.get(this.domainObject);
this.planViewConfiguration = new PlanViewConfiguration(this.domainObject, this.openmct);
this.configuration = this.planViewConfiguration.getConfiguration();
this.isNested = this.options.isChildObject;
this.swimlaneVisibility = this.configuration.swimlaneVisibility;
this.clipActivityNames = this.configuration.clipActivityNames;
if (this.domainObject.type === 'plan') {
this.planData = getValidatedData(this.domainObject);
}
const canvas = document.createElement('canvas');
this.canvasContext = canvas.getContext('2d');
this.setDimensions();
this.setTimeContext();
this.resizeTimer = setInterval(this.resize, RESIZE_POLL_INTERVAL);
this.setStatus(this.openmct.status.get(this.domainObject.identifier));
this.removeStatusListener = this.openmct.status.observe(this.domainObject.identifier, this.setStatus);
this.handleConfigurationChange(this.configuration);
this.planViewConfiguration.on('change', this.handleConfigurationChange);
this.stopObservingSelectFile = this.openmct.objects.observe(this.domainObject, 'selectFile', this.handleSelectFileChange);
this.loadComposition();
},
beforeDestroy() {
clearInterval(this.resizeTimer);
this.stopFollowingTimeContext();
if (this.unlisten) {
this.unlisten();
}
if (this.removeStatusListener) {
this.removeStatusListener();
}
if (this.composition) {
this.composition.off('add', this.handleCompositionAdd);
this.composition.off('remove', this.handleCompositionRemove);
}
this.planViewConfiguration.off('change', this.handleConfigurationChange);
this.stopObservingSelectFile();
this.planViewConfiguration.destroy();
},
methods: {
activityNameFitsRect(activityName, rectWidth) {
return (this.getTextWidth(activityName) + TEXT_LEFT_PADDING) < rectWidth;
},
setTimeContext() {
this.stopFollowingTimeContext();
this.timeContext = this.openmct.time.getContextForView(this.path);
this.followTimeContext();
},
followTimeContext() {
this.updateViewBounds(this.timeContext.bounds());
this.timeContext.on("timeSystem", this.setScaleAndGenerateActivities);
this.timeContext.on("bounds", this.updateViewBounds);
},
loadComposition() {
if (this.composition) {
this.composition.on('add', this.handleCompositionAdd);
this.composition.on('remove', this.handleCompositionRemove);
this.composition.load();
}
},
stopFollowingTimeContext() {
if (this.timeContext) {
this.timeContext.off("timeSystem", this.setScaleAndGenerateActivities);
this.timeContext.off("bounds", this.updateViewBounds);
}
},
showReplacePlanDialog(domainObject) {
const dialog = this.openmct.overlays.dialog({
iconClass: 'alert',
message: 'This action will replace the current Plan. Do you want to continue?',
buttons: [
{
label: 'Ok',
emphasis: true,
callback: () => {
this.removeFromComposition(this.planObject);
this.planObject = domainObject;
this.planData = getValidatedData(domainObject);
this.setScaleAndGenerateActivities();
dialog.dismiss();
}
},
{
label: 'Cancel',
callback: () => {
this.removeFromComposition(domainObject);
dialog.dismiss();
}
}
]
});
},
handleCompositionAdd(domainObject) {
if (this.planObject) {
this.showReplacePlanDialog(domainObject);
} else {
this.planObject = domainObject;
this.swimlaneVisibility = this.configuration.swimlaneVisibility;
this.planData = getValidatedData(domainObject);
this.setScaleAndGenerateActivities();
}
},
handleConfigurationChange(newConfiguration) {
Object.keys(newConfiguration).forEach((key) => {
this[key] = newConfiguration[key];
});
},
handleCompositionRemove(identifier) {
if (this.planObject && this.openmct.objects.areIdsEqual(identifier, this.planObject?.identifier)) {
this.planObject = null;
this.planData = {};
this.planViewConfiguration.resetSwimlaneVisibility();
}
this.setScaleAndGenerateActivities();
},
handleSelectFileChange() {
this.planData = getValidatedData(this.domainObject);
this.setScaleAndGenerateActivities();
},
removeFromComposition(domainObject) {
this.composition.remove(domainObject);
},
resize() {
let clientWidth = this.getClientWidth();
let clientHeight = this.getClientHeight();
if (clientWidth !== this.width) {
this.setDimensions();
this.updateViewBounds();
}
if (clientHeight !== this.height) {
this.setDimensions();
}
},
getClientWidth() {
let clientWidth = this.$refs.plan.clientWidth;
if (!clientWidth) {
//this is a hack - need a better way to find the parent of this component
let parent = this.openmct.layout.$refs.browseObject.$el;
if (parent) {
clientWidth = parent.getBoundingClientRect().width;
}
}
return clientWidth - 200;
},
getClientHeight() {
let clientHeight = this.$refs.plan.clientHeight;
if (!clientHeight) {
//this is a hack - need a better way to find the parent of this component
let parent = this.openmct.layout.$refs.browseObject.$el;
if (parent) {
clientHeight = parent.getBoundingClientRect().height;
}
}
return clientHeight;
},
updateViewBounds(bounds) {
if (bounds) {
this.viewBounds = bounds;
}
if (this.timeSystem === null) {
this.timeSystem = this.openmct.time.timeSystem();
}
this.setScaleAndGenerateActivities();
},
setScaleAndGenerateActivities(timeSystem) {
if (timeSystem) {
this.timeSystem = timeSystem;
}
this.setScale(this.timeSystem);
if (this.xScale) {
this.generateActivities();
}
},
setDimensions() {
this.width = this.getClientWidth();
this.height = this.getClientHeight();
},
setScale(timeSystem) {
if (!this.width) {
return;
}
if (!timeSystem) {
timeSystem = this.openmct.time.timeSystem();
}
if (timeSystem.isUTCBased) {
this.xScale = d3Scale.scaleUtc();
this.xScale.domain(
[new Date(this.viewBounds.start), new Date(this.viewBounds.end)]
);
} else {
this.xScale = d3Scale.scaleLinear();
this.xScale.domain(
[this.viewBounds.start, this.viewBounds.end]
);
}
this.xScale.range([PADDING, this.width - PADDING * 2]);
},
isActivityInBounds(activity) {
return (activity.start < this.viewBounds.end) && (activity.end > this.viewBounds.start);
},
/**
* Get the width of the given text in pixels.
* @param {string} text
* @returns {number} width of the text in pixels (as a double)
*/
getTextWidth(text) {
const textMetrics = this.canvasContext.measureText(text);
return textMetrics.width;
},
sortIntegerAsc(a, b) {
const numA = parseInt(a, 10);
const numB = parseInt(b, 10);
if (numA > numB) {
return 1;
}
if (numA < numB) {
return -1;
}
return 0;
},
/**
* Get the row where the next activity will land.
* @param {number} rectX the x coordinate of the activity rect
* @param {number} width the width of the activity rect
* @param {Object.<string, Array.<Object>>} activitiesByRow activity arrays mapped by row value
*/
getRowForActivity(rectX, rectWidth, activitiesByRow) {
const sortedActivityRows = Object.keys(activitiesByRow).sort(this.sortIntegerAsc);
let currentRow;
function activitiesHaveOverlap(rects) {
return rects.some(rect => {
const { rectStart, rectEnd } = rect;
const calculatedEnd = rectX + rectWidth;
const hasOverlap = (rectX >= rectStart && rectX <= rectEnd)
|| (calculatedEnd >= rectStart && calculatedEnd <= rectEnd)
|| (rectX <= rectStart && calculatedEnd >= rectEnd);
return hasOverlap;
});
}
for (let i = 0; i < sortedActivityRows.length; i++) {
let row = sortedActivityRows[i];
if (!activitiesHaveOverlap(activitiesByRow[row])) {
currentRow = row;
break;
}
}
if (currentRow === undefined && sortedActivityRows.length) {
let row = parseInt(sortedActivityRows[sortedActivityRows.length - 1], 10);
currentRow = row + ROW_HEIGHT + ROW_PADDING;
}
return currentRow || SWIMLANE_PADDING;
},
generateActivities() {
const groupNames = Object.keys(this.planData);
if (!groupNames.length) {
return;
}
const activityGroups = [];
this.planViewConfiguration.initializeSwimlaneVisibility(groupNames);
groupNames.forEach((groupName) => {
let activitiesByRow = {};
let currentRow = 0;
const rawActivities = this.planData[groupName];
rawActivities.forEach((rawActivity) => {
if (!this.isActivityInBounds(rawActivity)) {
return;
}
const currentStart = Math.max(this.viewBounds.start, rawActivity.start);
const currentEnd = Math.min(this.viewBounds.end, rawActivity.end);
const rectX1 = this.xScale(currentStart);
const rectX2 = this.xScale(currentEnd);
const rectWidth = Math.max(rectX2 - rectX1, MIN_ACTIVITY_WIDTH);
//TODO: Fix bug for SVG where the rectWidth is not proportional to the canvas measuredWidth of the text
const showTextInsideRect = this.clipActivityNames || this.activityNameFitsRect(rawActivity.name, rectWidth);
const textStart = (showTextInsideRect ? rectX1 : rectX2) + TEXT_LEFT_PADDING;
const color = rawActivity.color || DEFAULT_COLOR;
let textColor = '';
if (rawActivity.textColor) {
textColor = rawActivity.textColor;
} else if (showTextInsideRect) {
textColor = getContrastingColor(color);
}
const textLines = this.getActivityDisplayText(this.canvasContext, rawActivity.name, showTextInsideRect);
const textWidth = textStart + this.getTextWidth(textLines[0]) + TEXT_LEFT_PADDING;
if (showTextInsideRect) {
currentRow = this.getRowForActivity(rectX1, rectWidth, activitiesByRow);
} else {
currentRow = this.getRowForActivity(rectX1, textWidth, activitiesByRow);
}
let textY = parseInt(currentRow, 10) + (showTextInsideRect ? INNER_TEXT_PADDING : OUTER_TEXT_PADDING);
if (!activitiesByRow[currentRow]) {
activitiesByRow[currentRow] = [];
}
const activity = {
color: color,
textColor: textColor,
name: rawActivity.name,
exceeds: {
start: this.xScale(this.viewBounds.start) > this.xScale(rawActivity.start),
end: this.xScale(this.viewBounds.end) < this.xScale(rawActivity.end)
},
start: rawActivity.start,
end: rawActivity.end,
row: currentRow,
textLines: textLines,
textStart: textStart,
textClass: showTextInsideRect ? "" : "c-plan__activity-label--outside-rect",
textY: textY,
rectStart: rectX1,
rectEnd: showTextInsideRect ? rectX2 : textStart + textWidth,
rectWidth: rectWidth,
clipPathId: this.getClipPathId(groupName, rawActivity, currentRow)
};
activitiesByRow[currentRow].push(activity);
});
const { swimlaneHeight, swimlaneWidth } = this.getGroupDimensions(activitiesByRow);
const activities = Array.from(Object.values(activitiesByRow)).flat();
activityGroups.push({
heading: groupName,
activities,
height: swimlaneHeight,
width: swimlaneWidth,
status: this.isNested ? '' : this.status
});
});
this.activityGroups = activityGroups;
},
/**
* Format the activity name to fit within the activity rect with a max of 2 lines
* @param {CanvasRenderingContext2D} canvasContext
* @param {string} activityName
* @param {boolean} activityNameFitsRect
*/
getActivityDisplayText(canvasContext, activityName, activityNameFitsRect) {
// TODO: If the activity start is less than viewBounds.start then the text should be cropped on the left/should be off-screen)
let words = activityName.split(' ');
let line = '';
let activityLines = [];
for (let n = 0; (n < words.length) && (activityLines.length <= 2); n++) {
let tempLine = line + words[n] + ' ';
let textMetrics = canvasContext.measureText(tempLine);
const textWidth = textMetrics.width;
if (!activityNameFitsRect && (textWidth > MAX_TEXT_WIDTH && n > 0)) {
activityLines.push(line);
line = words[n] + ' ';
tempLine = line + words[n] + ' ';
}
line = tempLine;
}
return activityLines.length ? activityLines : [line];
},
getGroupDimensions(activityRows) {
let swimlaneHeight = 30;
let swimlaneWidth = this.width;
if (!activityRows) {
return {
swimlaneHeight,
swimlaneWidth
};
}
const rows = Object.keys(activityRows);
if (rows.length) {
const lastActivityRow = rows[rows.length - 1];
swimlaneHeight = parseInt(lastActivityRow, 10) + ROW_HEIGHT + SWIMLANE_PADDING;
swimlaneWidth = this.width;
}
return {
swimlaneHeight,
swimlaneWidth
};
},
setStatus(status) {
this.status = status;
},
getClipPathId(groupName, activity, row) {
groupName = groupName.toLowerCase().replace(/ /g, '-');
const activityName = activity.name.toLowerCase().replace(/ /g, '-');
return `${groupName}-${activityName}-${activity.start}-${activity.end}-${row}`;
}
}
};
</script>

View File

@ -20,13 +20,13 @@
* at runtime from the About dialog for additional information.
*****************************************************************************/
import PlanActivitiesView from "./PlanActivitiesView.vue";
import PlanActivitiesView from "./components/PlanActivitiesView.vue";
import Vue from 'vue';
export default function PlanInspectorViewProvider(openmct) {
export default function ActivityInspectorViewProvider(openmct) {
return {
key: 'plan-inspector',
name: 'Plan Inspector View',
key: 'activity-inspector',
name: 'Activity',
canView: function (selection) {
if (selection.length === 0 || selection[0].length === 0) {
return false;
@ -44,6 +44,7 @@ export default function PlanInspectorViewProvider(openmct) {
show: function (element) {
component = new Vue({
el: element,
name: "PlanActivitiesView",
components: {
PlanActivitiesView: PlanActivitiesView
},

View File

@ -0,0 +1,68 @@
/*****************************************************************************
* 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.
*****************************************************************************/
import PlanViewConfiguration from './components/PlanViewConfiguration.vue';
import Vue from 'vue';
export default function GanttChartInspectorViewProvider(openmct) {
return {
key: 'plan-inspector',
name: 'Config',
canView: function (selection) {
if (selection.length === 0 || selection[0].length === 0) {
return false;
}
const domainObject = selection[0][0].context.item;
return domainObject?.type === 'gantt-chart';
},
view: function (selection) {
let component;
return {
show: function (element) {
component = new Vue({
el: element,
components: {
PlanViewConfiguration
},
provide: {
openmct,
selection: selection
},
template: '<plan-view-configuration></plan-view-configuration>'
});
},
priority: function () {
return openmct.priority.HIGH + 1;
},
destroy: function () {
if (component) {
component.$destroy();
component = undefined;
}
}
};
}
};
}

View File

@ -36,16 +36,15 @@ import { getPreciseDuration } from "utils/duration";
import { v4 as uuid } from 'uuid';
const propertyLabels = {
'start': 'Start DateTime',
'end': 'End DateTime',
'duration': 'Duration',
'earliestStart': 'Earliest Start',
'latestEnd': 'Latest End',
'gap': 'Gap',
'overlap': 'Overlap',
'totalTime': 'Total Time'
start: 'Start DateTime',
end: 'End DateTime',
duration: 'Duration',
earliestStart: 'Earliest Start',
latestEnd: 'Latest End',
gap: 'Gap',
overlap: 'Overlap',
totalTime: 'Total Time'
};
export default {
components: {
PlanActivityView

View File

@ -0,0 +1,144 @@
<!--
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>
<div class="c-inspect-properties">
<div class="c-inspect-properties__header">
Swimlane Visibility
</div>
<ul class="c-inspect-properties__section">
<li
v-for="(visible, swimlaneName) in configuration.swimlaneVisibility"
:key="swimlaneName"
class="c-inspect-properties__row"
>
<div
class="c-inspect-properties__label"
title="Show or hide swimlane"
>
<label :for="swimlaneName + 'ColumnControl'">{{ swimlaneName }}</label>
</div>
<div class="c-inspect-properties__value">
<input
v-if="isEditing"
:id="swimlaneName + 'ColumnControl'"
type="checkbox"
:checked="visible === true"
@change="toggleHideSwimlane(swimlaneName)"
>
<div
v-else
class="value"
>
{{ visible === true ? 'Visible' : 'Hidden' }}
</div>
</div>
</li>
</ul>
<div class="c-inspect-properties__header">
Display settings
</div>
<ul class="c-inspect-properties__section">
<li class="c-inspect-properties__row">
<div
class="c-inspect-properties__label"
title="Clip Activity Names"
>
<label for="clipActivityNames">Clip Activity Names</label>
</div>
<div class="c-inspect-properties__value">
<input
v-if="isEditing"
id="clipActivityNames"
type="checkbox"
:checked="configuration.clipActivityNames === true"
@change="toggleClipActivityNames"
>
<div
v-else
class="value"
>
{{ configuration.clipActivityNames === true ? 'On' : 'Off' }}
</div>
</div></li>
</ul>
</div>
</template>
<script>
import PlanViewConfiguration from '../../PlanViewConfiguration';
export default {
components: {
},
inject: ['openmct'],
data() {
const selection = this.openmct.selection.get();
/** @type {import('../../../api/objects/ObjectAPI').DomainObject} */
const domainObject = selection[0][0].context.item;
const planViewConfiguration = new PlanViewConfiguration(domainObject, this.openmct);
return {
planViewConfiguration,
isEditing: this.openmct.editor.isEditing(),
configuration: planViewConfiguration.getConfiguration()
};
},
computed: {
canEdit() {
return this.isEditing;
}
},
mounted() {
this.openmct.editor.on('isEditing', this.setIsEditing);
this.planViewConfiguration.on('change', this.handleConfigurationChange);
},
beforeDestroy() {
this.openmct.editor.off('isEditing', this.setIsEditing);
this.planViewConfiguration.off('change', this.handleConfigurationChange);
},
methods: {
/**
* @param {Object.<string, any>} newConfiguration
*/
handleConfigurationChange(newConfiguration) {
this.configuration = newConfiguration;
},
/**
* @param {boolean} isEditing
*/
setIsEditing(isEditing) {
this.isEditing = isEditing;
},
toggleClipActivityNames() {
this.planViewConfiguration.setClipActivityNames(!this.configuration.clipActivityNames);
},
/**
* @param {string} swimlaneName
*/
toggleHideSwimlane(swimlaneName) {
this.planViewConfiguration.setSwimlaneVisibility(swimlaneName, !this.configuration.swimlaneVisibility[swimlaneName]);
}
}
};
</script>

View File

@ -21,21 +21,34 @@
*****************************************************************************/
.c-plan {
svg {
text-rendering: geometricPrecision;
svg {
text-rendering: geometricPrecision;
text {
stroke: none;
text {
stroke: none;
}
}
.activity-label {
&--outside-rect {
fill: $colorBodyFg !important;
}
}
}
&__activity {
cursor: pointer;
canvas {
display: none;
}
&[s-selected] {
rect, use {
outline-style: dotted;
outline-width: 2px;
stroke: $colorGanttSelectedBorder;
stroke-width: 2px;
}
}
}
&__activity-label {
&--outside-rect {
fill: $colorBodyFg !important;
}
}
canvas {
display: none;
}
}

View File

@ -21,15 +21,18 @@
*****************************************************************************/
import PlanViewProvider from './PlanViewProvider';
import PlanInspectorViewProvider from "./inspector/PlanInspectorViewProvider";
import ActivityInspectorViewProvider from "./inspector/ActivityInspectorViewProvider";
import GanttChartInspectorViewProvider from "./inspector/GanttChartInspectorViewProvider";
import ganttChartCompositionPolicy from './GanttChartCompositionPolicy';
import { DEFAULT_CONFIGURATION } from './PlanViewConfiguration';
export default function (configuration) {
export default function (options = {}) {
return function install(openmct) {
openmct.types.addType('plan', {
name: 'Plan',
key: 'plan',
description: 'A configurable timeline-like view for a compatible mission plan file.',
creatable: true,
description: 'A non-configurable timeline-like view for a compatible plan file.',
creatable: options.creatable ?? false,
cssClass: 'icon-plan',
form: [
{
@ -45,10 +48,30 @@ export default function (configuration) {
}
],
initialize: function (domainObject) {
domainObject.configuration = {
clipActivityNames: DEFAULT_CONFIGURATION.clipActivityNames
};
}
});
// Name TBD and subject to change
openmct.types.addType('gantt-chart', {
name: 'Gantt Chart',
key: 'gantt-chart',
description: 'A configurable timeline-like view for a compatible plan file.',
creatable: true,
cssClass: 'icon-plan',
form: [],
initialize(domainObject) {
domainObject.configuration = {
clipActivityNames: true
};
domainObject.composition = [];
}
});
openmct.objectViews.addProvider(new PlanViewProvider(openmct));
openmct.inspectorViews.addProvider(new PlanInspectorViewProvider(openmct));
openmct.inspectorViews.addProvider(new ActivityInspectorViewProvider(openmct));
openmct.inspectorViews.addProvider(new GanttChartInspectorViewProvider(openmct));
openmct.composition.addPolicy(ganttChartCompositionPolicy(openmct));
};
}

View File

@ -27,6 +27,7 @@ import Properties from "../inspectorViews/properties/Properties.vue";
describe('the plugin', function () {
let planDefinition;
let ganttDefinition;
let element;
let child;
let openmct;
@ -50,6 +51,7 @@ describe('the plugin', function () {
openmct.install(new PlanPlugin());
planDefinition = openmct.types.get('plan').definition;
ganttDefinition = openmct.types.get('gantt-chart').definition;
element = document.createElement('div');
element.style.width = '640px';
@ -74,15 +76,30 @@ describe('the plugin', function () {
let mockPlanObject = {
name: 'Plan',
key: 'plan',
creatable: false
};
let mockGanttObject = {
name: 'Gantt',
key: 'gantt-chart',
creatable: true
};
it('defines a plan object type with the correct key', () => {
expect(planDefinition.key).toEqual(mockPlanObject.key);
describe('the plan type', () => {
it('defines a plan object type with the correct key', () => {
expect(planDefinition.key).toEqual(mockPlanObject.key);
});
it('is not creatable', () => {
expect(planDefinition.creatable).toEqual(mockPlanObject.creatable);
});
});
it('is creatable', () => {
expect(planDefinition.creatable).toEqual(mockPlanObject.creatable);
describe('the gantt-chart type', () => {
it('defines a gantt-chart object type with the correct key', () => {
expect(ganttDefinition.key).toEqual(mockGanttObject.key);
});
it('is creatable', () => {
expect(ganttDefinition.creatable).toEqual(mockGanttObject.creatable);
});
});
describe('the plan view', () => {
@ -107,7 +124,7 @@ describe('the plugin', function () {
const applicableViews = openmct.objectViews.get(testViewObject, [testViewObject]);
let planView = applicableViews.find((viewProvider) => viewProvider.key === 'plan.view');
expect(planView.canEdit()).toBeFalse();
expect(planView.canEdit(testViewObject)).toBeFalse();
});
});
@ -179,10 +196,10 @@ describe('the plugin', function () {
it('displays the group label', () => {
const labelEl = element.querySelector('.c-plan__contents .c-object-label .c-object-label__name');
expect(labelEl.innerHTML).toEqual('TEST-GROUP');
expect(labelEl.innerHTML).toMatch(/TEST-GROUP/);
});
it('displays the activities and their labels', (done) => {
it('displays the activities and their labels', async () => {
const bounds = {
start: 1597160002854,
end: 1597181232854
@ -190,27 +207,22 @@ describe('the plugin', function () {
openmct.time.bounds(bounds);
Vue.nextTick(() => {
const rectEls = element.querySelectorAll('.c-plan__contents rect');
expect(rectEls.length).toEqual(2);
const textEls = element.querySelectorAll('.c-plan__contents text');
expect(textEls.length).toEqual(3);
done();
});
await Vue.nextTick();
const rectEls = element.querySelectorAll('.c-plan__contents use');
expect(rectEls.length).toEqual(2);
const textEls = element.querySelectorAll('.c-plan__contents text');
expect(textEls.length).toEqual(3);
});
it ('shows the status indicator when available', (done) => {
it ('shows the status indicator when available', async () => {
openmct.status.set({
key: "test-object",
namespace: ''
}, 'draft');
Vue.nextTick(() => {
const statusEl = element.querySelector('.c-plan__contents .is-status--draft');
expect(statusEl).toBeDefined();
done();
});
await Vue.nextTick();
const statusEl = element.querySelector('.c-plan__contents .is-status--draft');
expect(statusEl).toBeDefined();
});
});
@ -224,10 +236,12 @@ describe('the plugin', function () {
key: 'test-plan',
namespace: ''
},
created: 123456789,
modified: 123456790,
version: 'v1'
};
beforeEach(() => {
beforeEach(async () => {
openmct.selection.select([{
element: element,
context: {
@ -241,19 +255,18 @@ describe('the plugin', function () {
}
}], false);
return Vue.nextTick().then(() => {
let viewContainer = document.createElement('div');
child.append(viewContainer);
component = new Vue({
el: viewContainer,
components: {
Properties
},
provide: {
openmct: openmct
},
template: '<properties/>'
});
await Vue.nextTick();
let viewContainer = document.createElement('div');
child.append(viewContainer);
component = new Vue({
el: viewContainer,
components: {
Properties
},
provide: {
openmct: openmct
},
template: '<properties/>'
});
});
@ -264,7 +277,6 @@ describe('the plugin', function () {
it('provides an inspector view with the version information if available', () => {
componentObject = component.$root.$children[0];
const propertiesEls = componentObject.$el.querySelectorAll('.c-inspect-properties__row');
expect(propertiesEls.length).toEqual(7);
const found = Array.from(propertiesEls).some((propertyEl) => {
return (propertyEl.children[0].innerHTML.trim() === 'Version'
&& propertyEl.children[1].innerHTML.trim() === 'v1');

View File

@ -21,8 +21,8 @@
*****************************************************************************/
export function getValidatedData(domainObject) {
let sourceMap = domainObject.sourceMap;
let body = domainObject.selectFile?.body;
const sourceMap = domainObject.sourceMap;
const body = domainObject.selectFile?.body;
let json = {};
if (typeof body === 'string') {
try {
@ -64,3 +64,27 @@ export function getValidatedData(domainObject) {
return json;
}
}
export function getContrastingColor(hexColor) {
function cutHex(h, start, end) {
const hStr = (h.charAt(0) === '#') ? h.substring(1, 7) : h;
return parseInt(hStr.substring(start, end), 16);
}
// https://codepen.io/davidhalford/pen/ywEva/
const cThreshold = 130;
if (hexColor.indexOf('#') === -1) {
// We weren't given a hex color
return "#ff0000";
}
const hR = cutHex(hexColor, 0, 2);
const hG = cutHex(hexColor, 2, 4);
const hB = cutHex(hexColor, 4, 6);
const cBrightness = ((hR * 299) + (hG * 587) + (hB * 114)) / 1000;
return cBrightness > cThreshold ? "#000000" : "#ffffff";
}

View File

@ -19,10 +19,12 @@
* this source code distribution or the Licensing information page available
* at runtime from the About dialog for additional information.
*****************************************************************************/
const ALLOWED_TYPES = [
'telemetry.plot.overlay',
'telemetry.plot.stacked',
'plan'
'plan',
'gantt-chart'
];
const DISALLOWED_TYPES = [
'telemetry.plot.bar-graph',

View File

@ -19,12 +19,13 @@
* this source code distribution or the Licensing information page available
* at runtime from the About dialog for additional information.
*****************************************************************************/
<template>
<swim-lane
:icon-class="item.type.definition.cssClass"
:status="status"
:min-height="item.height"
:show-ucontents="item.domainObject.type === 'plan'"
:show-ucontents="isPlanLikeObject(item.domainObject)"
:span-rows-count="item.rowCount"
>
<template #label>
@ -128,6 +129,9 @@ export default {
},
setStatus(status) {
this.status = status;
},
isPlanLikeObject(domainObject) {
return domainObject.type === 'plan' || domainObject.type === 'gantt-chart';
}
}
};

View File

@ -111,6 +111,8 @@ export default {
let rowCount = 0;
if (domainObject.type === 'plan') {
rowCount = Object.keys(getValidatedData(domainObject)).length;
} else if (domainObject.type === 'gantt-chart') {
rowCount = Object.keys(domainObject.configuration.swimlaneVisibility).length;
}
let height = domainObject.type === 'telemetry.plot.stacked' ? `${domainObject.composition.length * 100}px` : '100px';

View File

@ -390,6 +390,7 @@ $colorCurrentBorder: $colorBodyBg;
$colorFutureBg: rgba($colorKey, 0.2);
$colorFutureFg: $colorCurrentFg;
$colorFutureBorder: $colorCurrentBorder;
$colorGanttSelectedBorder: rgba(#fff, 0.3);
// Tree
$colorTreeBg: transparent;

View File

@ -394,6 +394,7 @@ $colorCurrentBorder: #fff;
$colorFutureBg: rgba($colorKey, 0.2);
$colorFutureFg: $colorCurrentFg;
$colorFutureBorder: $colorCurrentBorder;
$colorGanttSelectedBorder: #fff;
// Tree
$colorTreeBg: transparent;

View File

@ -390,6 +390,7 @@ $colorCurrentBorder: #fff;
$colorFutureBg: rgba($colorKey, 0.2);
$colorFutureFg: $colorCurrentFg;
$colorFutureBorder: $colorCurrentBorder;
$colorGanttSelectedBorder: #fff;
// Tree
$colorTreeBg: transparent;

View File

@ -1,3 +1,25 @@
/*****************************************************************************
* 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>
<div
class="u-contents"
@ -68,6 +90,12 @@ export default {
return false;
}
},
isHidden: {
type: Boolean,
default() {
return false;
}
},
hideLabel: {
type: Boolean,
default() {

View File

@ -1,3 +1,25 @@
/*****************************************************************************
* 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.
*****************************************************************************/
.c-swimlane {
display: grid;
grid-template-columns: 100px 100px 1fr;
@ -32,4 +54,5 @@
// Yet more brittle special case selecting...
.is-object-type-plan { display: contents; }
.is-object-type-gantt-chart { display: contents; }
}

View File

@ -44,7 +44,7 @@
v-if="singleSelectNonObject"
class="c-inspector__selected c-inspector__selected--non-domain-object c-object-label"
>
<span class="c-object-label__name">Layout Object</span>
<span class="c-object-label__name">{{ heading }}</span>
</div>
</div>
<div
@ -74,6 +74,13 @@ export default {
item() {
return this.domainObject || {};
},
heading() {
if (this.activity) {
return this.activity.name;
}
return 'Layout Item';
},
type() {
return this.openmct.types.get(this.item.type);
},