Files
openmct/src/plugins/plan/components/Plan.vue
Jesse Mazzella ff3a20e446 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>
2023-03-16 10:34:31 -07:00

559 lines
21 KiB
Vue

* 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>