mirror of
https://github.com/nasa/openmct.git
synced 2025-01-29 15:43:52 +00:00
Time strip view to show different components within a given timeline (#3654)
* Initial commit of plot refactor for vuejs * Use es6 classes instead of using extend * Use classList api to add and remove classes * Remove angular specific event mechanisms * Refactor plot legend into smaller components * Refactor moving config into MctPlot component. Fix Legend issues. * Refactor XAxis and YAxis into their own components * Remove commented out code * Remove empty initialize method * Fix grid lines and initialize function revert. * Check that plots views are available only to domainObjects that have range and domain * Make css class a computed property * Remove unnecessary legacyObject conversion * Remove comments and commented out code * Remove use of private for vue methods * Remove console logs * Fixes Y-axis ticks display * Add plots and plans to the time strip view * Adds stacked plots and overlay plots * Fix css for stacked plots * Disable Vue plots * Rename Stacked plot item component * Make the time axis a component Ensure plans and timelines use the time axis component and it is displayed correctly ensure plots don't display specific controls when in compact mode * Add missing file * Revert change to state generator metadata * Address Review comment: Remove unnecessary event emitted * Address review comments: Add a note about why nextTick is needed * Display time systems in time strip view Update look and feel (css) * Fix bug with legend when multiple plots are being displayed * Don't show action buttons for stacked plots * Changes to plan view to render as a css grid * Change LinearScale to a class * Remove duplicated comment * Adds missing copyright info * Revert change to stackedplotItem * Styling for Timestrip view WIP - Significant mods to markup and CSS to use CSS grid; - CSS class names changed; * Styling for Timestrip view WIP - Temp mods to illustrate design desires for the appearance of the time axis; * Layout changes for plan in timestrip view * Increase style height to match number of stacked plot items * Fix ticks * Fix removal of activities * Remove event listeners on destroy * Styling for Timestrip view WIP - VERY WIP trying to make the plan component work properly when dropped into a Timestrip view, lots of badness that needs to be fixed; - Refined classes in acivity bars to differentiate between the rect and its text; * Show Vue plots only in timestrip view. Reorder and Remove now works for timestrip objects * Make swim lanes a component to be reused by time strip and plan views Rewrite svg rendering to use javascript rather than d3. Write a prototype of foreign object for svg to render text * Don't show left and right edges when start or end is out of bounds * Descriptive name for Plan views * Adds plan icon and name * Fixes linting issues * Adds basic tests * Fixes broken test. * Adds new test * Fix linting errors. Adds tests * Adds tests * Adds tests for stacked plots * Adds more tests * Removes fdescribe * Adds tests for y-axis ticks * Tests for addition of series to plots * Adds more tests * Adds cursor guides test * Adds tests for interceptors * Adds more plots tests for x and y scale * Use config store * Adding goToOriginalAction tests * Fix tests for plan and time strip views * Fixes height of SVG * Fixes broken tests * Address review comments: remove view options API change. * Remove commented out code * Fix tests * Use the clientWidth of the plan if it's available * Account for the width of labels in the client width * Remove unnecessary test code Co-authored-by: charlesh88 <charlesh88@gmail.com> Co-authored-by: Andrew Henry <akhenry@gmail.com>
This commit is contained in:
parent
f789775b1c
commit
169eec0a51
@ -86,7 +86,9 @@
|
||||
openmct.install(openmct.plugins.MyItems());
|
||||
openmct.install(openmct.plugins.Generator());
|
||||
openmct.install(openmct.plugins.ExampleImagery());
|
||||
openmct.install(openmct.plugins.PlanLayout());
|
||||
openmct.install(openmct.plugins.Timeline());
|
||||
openmct.install(openmct.plugins.PlotVue());
|
||||
openmct.install(openmct.plugins.UTCTimeSystem());
|
||||
openmct.install(openmct.plugins.AutoflowView({
|
||||
type: "telemetry.panel"
|
||||
|
455
src/plugins/plan/Plan.vue
Normal file
455
src/plugins/plan/Plan.vue
Normal file
@ -0,0 +1,455 @@
|
||||
<template>
|
||||
<div ref="plan"
|
||||
class="c-plan"
|
||||
>
|
||||
<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 d3Selection from 'd3-selection';
|
||||
import * as d3Scale from 'd3-scale';
|
||||
import TimelineAxis from "../../ui/components/TimeSystemAxis.vue";
|
||||
import SwimLane from "@/ui/components/swim-lane/SwimLane.vue";
|
||||
import Vue from "vue";
|
||||
|
||||
//TODO: UI direction needed for the following property values
|
||||
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 = 10;
|
||||
const DEFAULT_COLOR = 'yellow';
|
||||
const DEFAULT_TEXT_COLOR = 'white';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
TimelineAxis,
|
||||
SwimLane
|
||||
},
|
||||
inject: ['openmct', 'domainObject'],
|
||||
props: {
|
||||
options: {
|
||||
type: Object,
|
||||
default() {
|
||||
return {
|
||||
compact: false
|
||||
};
|
||||
}
|
||||
},
|
||||
renderingEngine: {
|
||||
type: String,
|
||||
default() {
|
||||
return 'svg';
|
||||
}
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
viewBounds: undefined,
|
||||
timeSystem: undefined,
|
||||
height: 0
|
||||
};
|
||||
},
|
||||
mounted() {
|
||||
this.validateJSON(this.domainObject.selectFile.body);
|
||||
|
||||
this.canvas = this.$refs.plan.appendChild(document.createElement('canvas'));
|
||||
this.canvas.height = 0;
|
||||
this.canvasContext = this.canvas.getContext('2d');
|
||||
|
||||
this.setDimensions();
|
||||
this.updateViewBounds();
|
||||
this.openmct.time.on("timeSystem", this.setScaleAndPlotActivities);
|
||||
this.openmct.time.on("bounds", this.updateViewBounds);
|
||||
this.resizeTimer = setInterval(this.resize, RESIZE_POLL_INTERVAL);
|
||||
this.unlisten = this.openmct.objects.observe(this.domainObject, '*', this.observeForChanges);
|
||||
},
|
||||
destroyed() {
|
||||
clearInterval(this.resizeTimer);
|
||||
this.openmct.time.off("timeSystem", this.setScaleAndPlotActivities);
|
||||
this.openmct.time.off("bounds", this.updateViewBounds);
|
||||
if (this.unlisten) {
|
||||
this.unlisten();
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
observeForChanges(mutatedObject) {
|
||||
this.validateJSON(mutatedObject.selectFile.body);
|
||||
this.setScaleAndPlotActivities();
|
||||
},
|
||||
resize() {
|
||||
let clientWidth = this.getClientWidth();
|
||||
if (clientWidth !== this.width) {
|
||||
this.setDimensions();
|
||||
this.updateViewBounds();
|
||||
}
|
||||
},
|
||||
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;
|
||||
},
|
||||
validateJSON(jsonString) {
|
||||
try {
|
||||
this.json = JSON.parse(jsonString);
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
updateViewBounds() {
|
||||
this.viewBounds = this.openmct.time.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() {
|
||||
d3Selection.selectAll(".c-plan__contents > div").remove();
|
||||
},
|
||||
setDimensions() {
|
||||
const planHolder = this.$refs.plan;
|
||||
this.width = this.getClientWidth();
|
||||
|
||||
this.height = Math.round(planHolder.getBoundingClientRect().height);
|
||||
},
|
||||
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.json);
|
||||
this.groupActivities = {};
|
||||
|
||||
groups.forEach((key, index) => {
|
||||
let activitiesByRow = {};
|
||||
let currentRow = 0;
|
||||
|
||||
let activities = this.json[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;
|
||||
|
||||
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: activity.color || DEFAULT_COLOR,
|
||||
textColor: activity.textColor || DEFAULT_TEXT_COLOR,
|
||||
name: activity.name,
|
||||
exceeds: {
|
||||
start: this.xScale(this.viewBounds.start) > this.xScale(activity.start),
|
||||
end: this.xScale(this.viewBounds.end) < this.xScale(activity.end)
|
||||
}
|
||||
},
|
||||
textLines: textLines,
|
||||
textStart: textStart,
|
||||
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;
|
||||
|
||||
if (rows.length) {
|
||||
const lastActivityRow = rows[rows.length - 1];
|
||||
svgHeight = parseInt(lastActivityRow, 10) + ROW_HEIGHT;
|
||||
svgWidth = this.width;
|
||||
}
|
||||
|
||||
let component = new Vue({
|
||||
components: {
|
||||
SwimLane
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
heading,
|
||||
isNested,
|
||||
height: svgHeight,
|
||||
width: svgWidth
|
||||
};
|
||||
},
|
||||
template: `<swim-lane :is-nested="isNested"><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 => {
|
||||
//TODO: Don't draw the left-border of the rectangle if the activity started before viewBounds.start
|
||||
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: "no-activities"
|
||||
});
|
||||
textElement.innerHTML = 'No activities at this time';
|
||||
|
||||
svgElement.appendChild(textElement);
|
||||
},
|
||||
setNSAttributesForElement(element, attributes) {
|
||||
Object.keys(attributes).forEach((key) => {
|
||||
element.setAttributeNS(null, key, attributes[key]);
|
||||
});
|
||||
},
|
||||
// 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;
|
||||
}
|
||||
|
||||
this.setNSAttributesForElement(rectElement, {
|
||||
class: 'activity-bounds',
|
||||
x: item.activity.exceeds.start ? item.start - EDGE_ROUNDING : item.start,
|
||||
y: row,
|
||||
rx: EDGE_ROUNDING,
|
||||
width: width,
|
||||
height: String(ROW_HEIGHT),
|
||||
fill: activity.color
|
||||
});
|
||||
|
||||
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',
|
||||
x: item.textStart,
|
||||
y: item.textY + (index * LINE_HEIGHT),
|
||||
fill: activity.textColor
|
||||
});
|
||||
|
||||
const textNode = document.createTextNode(line);
|
||||
textElement.appendChild(textNode);
|
||||
svgElement.appendChild(textElement);
|
||||
});
|
||||
// this.addForeignElement(svgElement, activity.name, item.textStart, item.textY - LINE_HEIGHT);
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
77
src/plugins/plan/PlanViewProvider.js
Normal file
77
src/plugins/plan/PlanViewProvider.js
Normal file
@ -0,0 +1,77 @@
|
||||
/*****************************************************************************
|
||||
* Open MCT, Copyright (c) 2014-2020, 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 Plan from './Plan.vue';
|
||||
import Vue from 'vue';
|
||||
|
||||
export default function PlanViewProvider(openmct) {
|
||||
function isCompactView(objectPath) {
|
||||
return objectPath.find(object => object.type === 'time-strip') !== undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
key: 'plan.view',
|
||||
name: 'Plan',
|
||||
cssClass: 'icon-calendar',
|
||||
canView(domainObject) {
|
||||
return domainObject.type === 'plan';
|
||||
},
|
||||
|
||||
canEdit(domainObject) {
|
||||
return domainObject.type === 'plan';
|
||||
},
|
||||
|
||||
view: function (domainObject, objectPath) {
|
||||
let component;
|
||||
|
||||
return {
|
||||
show: function (element) {
|
||||
let isCompact = isCompactView(objectPath);
|
||||
|
||||
component = new Vue({
|
||||
el: element,
|
||||
components: {
|
||||
Plan
|
||||
},
|
||||
provide: {
|
||||
openmct,
|
||||
domainObject
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
options: {
|
||||
compact: isCompact,
|
||||
isChildObject: isCompact
|
||||
}
|
||||
};
|
||||
},
|
||||
template: '<plan :options="options"></plan>'
|
||||
});
|
||||
},
|
||||
destroy: function () {
|
||||
component.$destroy();
|
||||
component = undefined;
|
||||
}
|
||||
};
|
||||
}
|
||||
};
|
||||
}
|
24
src/plugins/plan/plan.scss
Normal file
24
src/plugins/plan/plan.scss
Normal file
@ -0,0 +1,24 @@
|
||||
.c-plan {
|
||||
|
||||
@include abs();
|
||||
|
||||
svg {
|
||||
text-rendering: geometricPrecision;
|
||||
|
||||
.activity-label, .no-activities {
|
||||
stroke: none;
|
||||
}
|
||||
|
||||
.no-activities {
|
||||
fill: #383838;
|
||||
}
|
||||
|
||||
.activity-bounds {
|
||||
fill-opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
canvas {
|
||||
display: none;
|
||||
}
|
||||
}
|
49
src/plugins/plan/plugin.js
Normal file
49
src/plugins/plan/plugin.js
Normal file
@ -0,0 +1,49 @@
|
||||
/*****************************************************************************
|
||||
* Open MCT, Copyright (c) 2014-2020, 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 PlanViewProvider from './PlanViewProvider';
|
||||
|
||||
export default function () {
|
||||
return function install(openmct) {
|
||||
openmct.types.addType('plan', {
|
||||
name: 'Plan',
|
||||
key: 'plan',
|
||||
description: 'A plan',
|
||||
creatable: true,
|
||||
cssClass: 'icon-calendar',
|
||||
form: [
|
||||
{
|
||||
name: 'Upload Plan (JSON File)',
|
||||
key: 'selectFile',
|
||||
control: 'file-input',
|
||||
required: true,
|
||||
text: 'Select File',
|
||||
type: 'application/json'
|
||||
}
|
||||
],
|
||||
initialize: function (domainObject) {
|
||||
}
|
||||
});
|
||||
openmct.objectViews.addProvider(new PlanViewProvider(openmct));
|
||||
};
|
||||
}
|
||||
|
166
src/plugins/plan/pluginSpec.js
Normal file
166
src/plugins/plan/pluginSpec.js
Normal file
@ -0,0 +1,166 @@
|
||||
/*****************************************************************************
|
||||
* Open MCT, Copyright (c) 2014-2020, 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 {createOpenMct, resetApplicationState} from "utils/testing";
|
||||
import PlanPlugin from "../plan/plugin";
|
||||
import Vue from 'vue';
|
||||
|
||||
describe('the plugin', function () {
|
||||
let planDefinition;
|
||||
let element;
|
||||
let child;
|
||||
let openmct;
|
||||
|
||||
beforeEach((done) => {
|
||||
const appHolder = document.createElement('div');
|
||||
appHolder.style.width = '640px';
|
||||
appHolder.style.height = '480px';
|
||||
|
||||
openmct = createOpenMct();
|
||||
openmct.install(new PlanPlugin());
|
||||
|
||||
planDefinition = openmct.types.get('plan').definition;
|
||||
|
||||
element = document.createElement('div');
|
||||
element.style.width = '640px';
|
||||
element.style.height = '480px';
|
||||
child = document.createElement('div');
|
||||
child.style.width = '640px';
|
||||
child.style.height = '480px';
|
||||
element.appendChild(child);
|
||||
|
||||
openmct.time.timeSystem('utc', {
|
||||
start: 1597160002854,
|
||||
end: 1597181232854
|
||||
});
|
||||
openmct.on('start', done);
|
||||
openmct.start(appHolder);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
return resetApplicationState(openmct);
|
||||
});
|
||||
|
||||
let mockPlanObject = {
|
||||
name: 'Plan',
|
||||
key: 'plan',
|
||||
creatable: true
|
||||
};
|
||||
|
||||
it('defines a plan object type with the correct key', () => {
|
||||
expect(planDefinition.key).toEqual(mockPlanObject.key);
|
||||
});
|
||||
|
||||
it('is creatable', () => {
|
||||
expect(planDefinition.creatable).toEqual(mockPlanObject.creatable);
|
||||
});
|
||||
|
||||
describe('the plan view', () => {
|
||||
|
||||
it('provides a plan view', () => {
|
||||
const testViewObject = {
|
||||
id: "test-object",
|
||||
type: "plan"
|
||||
};
|
||||
|
||||
const applicableViews = openmct.objectViews.get(testViewObject);
|
||||
let planView = applicableViews.find((viewProvider) => viewProvider.key === 'plan.view');
|
||||
expect(planView).toBeDefined();
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
describe('the plan view displays activities', () => {
|
||||
let planDomainObject;
|
||||
let mockObjectPath = [
|
||||
{
|
||||
identifier: {
|
||||
key: 'test',
|
||||
namespace: ''
|
||||
},
|
||||
type: 'time-strip',
|
||||
name: 'Test Parent Object'
|
||||
}
|
||||
];
|
||||
let planView;
|
||||
|
||||
beforeEach((done) => {
|
||||
planDomainObject = {
|
||||
identifier: {
|
||||
key: 'test-object',
|
||||
namespace: ''
|
||||
},
|
||||
type: 'plan',
|
||||
id: "test-object",
|
||||
selectFile: {
|
||||
body: JSON.stringify({
|
||||
"TEST-GROUP": [
|
||||
{
|
||||
"name": "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua",
|
||||
"start": 1597170002854,
|
||||
"end": 1597171032854,
|
||||
"type": "TEST-GROUP",
|
||||
"color": "fuchsia",
|
||||
"textColor": "black"
|
||||
},
|
||||
{
|
||||
"name": "Sed ut perspiciatis",
|
||||
"start": 1597171132854,
|
||||
"end": 1597171232854,
|
||||
"type": "TEST-GROUP",
|
||||
"color": "fuchsia",
|
||||
"textColor": "black"
|
||||
}
|
||||
]
|
||||
})
|
||||
}
|
||||
};
|
||||
|
||||
const applicableViews = openmct.objectViews.get(planDomainObject);
|
||||
planView = applicableViews.find((viewProvider) => viewProvider.key === 'plan.view');
|
||||
let view = planView.view(planDomainObject, mockObjectPath);
|
||||
view.show(child, true);
|
||||
|
||||
return Vue.nextTick().then(() => {
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
it('loads activities into the view', () => {
|
||||
const svgEls = element.querySelectorAll('.c-plan__contents svg');
|
||||
expect(svgEls.length).toEqual(1);
|
||||
});
|
||||
|
||||
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');
|
||||
});
|
||||
|
||||
it('displays the activities and their labels', () => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
});
|
@ -24,6 +24,10 @@ import Plot from '../single/Plot.vue';
|
||||
import Vue from 'vue';
|
||||
|
||||
export default function OverlayPlotViewProvider(openmct) {
|
||||
function isCompactView(objectPath) {
|
||||
return objectPath.find(object => object.type === 'time-strip');
|
||||
}
|
||||
|
||||
return {
|
||||
key: 'plot-overlay',
|
||||
name: 'Overlay Plot',
|
||||
@ -36,11 +40,12 @@ export default function OverlayPlotViewProvider(openmct) {
|
||||
return domainObject.type === 'telemetry.plot.overlay';
|
||||
},
|
||||
|
||||
view: function (domainObject) {
|
||||
view: function (domainObject, objectPath) {
|
||||
let component;
|
||||
|
||||
return {
|
||||
show: function (element) {
|
||||
let isCompact = isCompactView(objectPath);
|
||||
component = new Vue({
|
||||
el: element,
|
||||
components: {
|
||||
@ -50,7 +55,14 @@ export default function OverlayPlotViewProvider(openmct) {
|
||||
openmct,
|
||||
domainObject
|
||||
},
|
||||
template: '<plot></plot>'
|
||||
data() {
|
||||
return {
|
||||
options: {
|
||||
compact: isCompact
|
||||
}
|
||||
};
|
||||
},
|
||||
template: '<plot :options="options"></plot>'
|
||||
});
|
||||
},
|
||||
destroy: function () {
|
||||
|
@ -50,7 +50,7 @@
|
||||
></span>
|
||||
</div>
|
||||
|
||||
<mct-ticks v-show="gridLines"
|
||||
<mct-ticks v-show="gridLines && !options.compact"
|
||||
:axis-type="'xAxis'"
|
||||
:position="'right'"
|
||||
@plotTickWidth="onTickWidthChange"
|
||||
@ -113,7 +113,7 @@
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<x-axis v-if="config.series.models.length > 0"
|
||||
<x-axis v-if="config.series.models.length > 0 && !options.compact"
|
||||
:series-model="config.series.models[0]"
|
||||
/>
|
||||
|
||||
@ -146,6 +146,14 @@ export default {
|
||||
},
|
||||
inject: ['openmct', 'domainObject'],
|
||||
props: {
|
||||
options: {
|
||||
type: Object,
|
||||
default() {
|
||||
return {
|
||||
compact: false
|
||||
};
|
||||
}
|
||||
},
|
||||
gridLines: {
|
||||
type: Boolean,
|
||||
default() {
|
||||
@ -885,6 +893,9 @@ export default {
|
||||
if (this.filterObserver) {
|
||||
this.filterObserver();
|
||||
}
|
||||
|
||||
this.openmct.time.off('bounds', this.updateDisplayBounds);
|
||||
this.openmct.objectViews.off('clearData', this.clearData);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
@ -76,7 +76,7 @@
|
||||
|
||||
<script>
|
||||
import eventHelpers from "./lib/eventHelpers";
|
||||
import { ticks, commonPrefix, commonSuffix } from "./tickUtils";
|
||||
import { ticks, getFormattedTicks } from "./tickUtils";
|
||||
import configStore from "./configuration/configStore";
|
||||
|
||||
export default {
|
||||
@ -208,29 +208,7 @@ export default {
|
||||
step: newTicks[1] - newTicks[0]
|
||||
};
|
||||
|
||||
newTicks = newTicks
|
||||
.map(function (tickValue) {
|
||||
return {
|
||||
value: tickValue,
|
||||
text: format(tickValue)
|
||||
};
|
||||
}, this);
|
||||
|
||||
if (newTicks.length && typeof newTicks[0].text === 'string') {
|
||||
const tickText = newTicks.map(function (t) {
|
||||
return t.text;
|
||||
});
|
||||
const prefix = tickText.reduce(commonPrefix);
|
||||
const suffix = tickText.reduce(commonSuffix);
|
||||
newTicks.forEach(function (t) {
|
||||
t.fullText = t.text;
|
||||
if (suffix.length) {
|
||||
t.text = t.text.slice(prefix.length, -suffix.length);
|
||||
} else {
|
||||
t.text = t.text.slice(prefix.length);
|
||||
}
|
||||
});
|
||||
}
|
||||
newTicks = getFormattedTicks(newTicks, format);
|
||||
|
||||
this.ticks = newTicks;
|
||||
this.shouldCheckWidth = true;
|
||||
|
@ -23,7 +23,9 @@
|
||||
<div ref="plotWrapper"
|
||||
class="c-plot holder holder-plot has-control-bar"
|
||||
>
|
||||
<div class="c-control-bar">
|
||||
<div v-if="!options.compact"
|
||||
class="c-control-bar"
|
||||
>
|
||||
<span class="c-button-set c-button-set--strip-h">
|
||||
<button class="c-button icon-download"
|
||||
title="Export This View's Data as PNG"
|
||||
@ -60,6 +62,7 @@
|
||||
></div>
|
||||
<mct-plot :grid-lines="gridLines"
|
||||
:cursor-guide="cursorGuide"
|
||||
:options="options"
|
||||
@loadingUpdated="loadingUpdated"
|
||||
/>
|
||||
</div>
|
||||
@ -75,12 +78,22 @@ export default {
|
||||
MctPlot
|
||||
},
|
||||
inject: ['openmct', 'domainObject'],
|
||||
props: {
|
||||
options: {
|
||||
type: Object,
|
||||
default() {
|
||||
return {
|
||||
compact: false
|
||||
};
|
||||
}
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
//Don't think we need this as it appears to be stacked plot specific
|
||||
// hideExportButtons: false
|
||||
cursorGuide: false,
|
||||
gridLines: true,
|
||||
gridLines: !this.options.compact,
|
||||
loading: false
|
||||
};
|
||||
},
|
||||
|
@ -39,19 +39,24 @@ export default function PlotViewProvider(openmct) {
|
||||
&& metadata.valuesForHints(['domain']).length > 0);
|
||||
}
|
||||
|
||||
function isCompactView(objectPath) {
|
||||
return objectPath.find(object => object.type === 'time-strip');
|
||||
}
|
||||
|
||||
return {
|
||||
key: 'plot-single',
|
||||
key: 'plot-simple',
|
||||
name: 'Plot',
|
||||
cssClass: 'icon-telemetry',
|
||||
canView(domainObject) {
|
||||
return domainObject.type === 'plot-single' || hasTelemetry(domainObject);
|
||||
return hasTelemetry(domainObject, openmct);
|
||||
},
|
||||
|
||||
view: function (domainObject) {
|
||||
view: function (domainObject, objectPath) {
|
||||
let component;
|
||||
|
||||
return {
|
||||
show: function (element) {
|
||||
let isCompact = isCompactView(objectPath);
|
||||
component = new Vue({
|
||||
el: element,
|
||||
components: {
|
||||
@ -61,7 +66,14 @@ export default function PlotViewProvider(openmct) {
|
||||
openmct,
|
||||
domainObject
|
||||
},
|
||||
template: '<plot></plot>'
|
||||
data() {
|
||||
return {
|
||||
options: {
|
||||
compact: isCompact
|
||||
}
|
||||
};
|
||||
},
|
||||
template: '<plot :options="options"></plot>'
|
||||
});
|
||||
},
|
||||
destroy: function () {
|
||||
|
@ -135,7 +135,7 @@ describe("the plugin", function () {
|
||||
};
|
||||
|
||||
const applicableViews = openmct.objectViews.get(testTelemetryObject);
|
||||
let plotView = applicableViews.find((viewProvider) => viewProvider.key === "plot-single");
|
||||
let plotView = applicableViews.find((viewProvider) => viewProvider.key === "plot-simple");
|
||||
expect(plotView).toBeDefined();
|
||||
});
|
||||
|
||||
@ -219,7 +219,7 @@ describe("the plugin", function () {
|
||||
};
|
||||
|
||||
applicableViews = openmct.objectViews.get(testTelemetryObject);
|
||||
plotViewProvider = applicableViews.find((viewProvider) => viewProvider.key === "plot-single");
|
||||
plotViewProvider = applicableViews.find((viewProvider) => viewProvider.key === "plot-simple");
|
||||
plotView = plotViewProvider.view(testTelemetryObject, [testTelemetryObject]);
|
||||
plotView.show(child, true);
|
||||
|
||||
|
@ -87,3 +87,31 @@ export function commonSuffix(a, b) {
|
||||
|
||||
return a.slice(a.length - breakpoint);
|
||||
}
|
||||
|
||||
export function getFormattedTicks(newTicks, format) {
|
||||
newTicks = newTicks
|
||||
.map(function (tickValue) {
|
||||
return {
|
||||
value: tickValue,
|
||||
text: format(tickValue)
|
||||
};
|
||||
});
|
||||
|
||||
if (newTicks.length && typeof newTicks[0].text === 'string') {
|
||||
const tickText = newTicks.map(function (t) {
|
||||
return t.text;
|
||||
});
|
||||
const prefix = tickText.reduce(commonPrefix);
|
||||
const suffix = tickText.reduce(commonSuffix);
|
||||
newTicks.forEach(function (t) {
|
||||
t.fullText = t.text;
|
||||
if (suffix.length) {
|
||||
t.text = t.text.slice(prefix.length, -suffix.length);
|
||||
} else {
|
||||
t.text = t.text.slice(prefix.length);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return newTicks;
|
||||
}
|
||||
|
@ -22,7 +22,7 @@
|
||||
|
||||
<template>
|
||||
<div class="c-plot c-plot--stacked holder holder-plot has-control-bar">
|
||||
<div v-show="!hideExportButtons"
|
||||
<div v-show="!hideExportButtons && !options.compact"
|
||||
class="c-control-bar"
|
||||
>
|
||||
<span class="c-button-set c-button-set--strip-h">
|
||||
@ -56,6 +56,7 @@
|
||||
:key="object.id"
|
||||
class="c-plot--stacked-container"
|
||||
:object="object"
|
||||
:options="options"
|
||||
:grid-lines="gridLines"
|
||||
:cursor-guide="cursorGuide"
|
||||
:plot-tick-width="maxTickWidth"
|
||||
@ -74,6 +75,14 @@ export default {
|
||||
StackedPlotItem
|
||||
},
|
||||
inject: ['openmct', 'domainObject', 'composition'],
|
||||
props: {
|
||||
options: {
|
||||
type: Object,
|
||||
default() {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
hideExportButtons: false,
|
||||
|
@ -36,6 +36,12 @@ export default {
|
||||
return {};
|
||||
}
|
||||
},
|
||||
options: {
|
||||
type: Object,
|
||||
default() {
|
||||
return {};
|
||||
}
|
||||
},
|
||||
gridLines: {
|
||||
type: Boolean,
|
||||
default() {
|
||||
@ -108,7 +114,7 @@ export default {
|
||||
loadingUpdated
|
||||
};
|
||||
},
|
||||
template: '<div ref="plotWrapper" class="l-view-section u-style-receiver js-style-receiver"><div v-show="!!loading" class="c-loading--overlay loading"></div><mct-plot :grid-lines="gridLines" :cursor-guide="cursorGuide" :plot-tick-width="plotTickWidth" @plotTickWidth="onTickWidthChange" @loadingUpdated="loadingUpdated"/></div>'
|
||||
template: '<div ref="plotWrapper" class="l-view-section u-style-receiver js-style-receiver"><div v-show="!!loading" class="c-loading--overlay loading"></div><mct-plot :grid-lines="gridLines" :cursor-guide="cursorGuide" :plot-tick-width="plotTickWidth" :options="options" @plotTickWidth="onTickWidthChange" @loadingUpdated="loadingUpdated"/></div>'
|
||||
});
|
||||
},
|
||||
onTickWidthChange() {
|
||||
@ -122,7 +128,8 @@ export default {
|
||||
gridLines: this.gridLines,
|
||||
cursorGuide: this.cursorGuide,
|
||||
plotTickWidth: this.plotTickWidth,
|
||||
loading: this.loading
|
||||
loading: this.loading,
|
||||
options: this.options
|
||||
};
|
||||
}
|
||||
}
|
||||
|
@ -24,6 +24,10 @@ import StackedPlot from './StackedPlot.vue';
|
||||
import Vue from 'vue';
|
||||
|
||||
export default function StackedPlotViewProvider(openmct) {
|
||||
function isCompactView(objectPath) {
|
||||
return objectPath.find(object => object.type === 'time-strip');
|
||||
}
|
||||
|
||||
return {
|
||||
key: 'plot-stacked',
|
||||
name: 'Stacked Plot',
|
||||
@ -36,11 +40,13 @@ export default function StackedPlotViewProvider(openmct) {
|
||||
return domainObject.type === 'telemetry.plot.stacked';
|
||||
},
|
||||
|
||||
view: function (domainObject) {
|
||||
view: function (domainObject, objectPath) {
|
||||
let component;
|
||||
|
||||
return {
|
||||
show: function (element) {
|
||||
let isCompact = isCompactView(objectPath);
|
||||
|
||||
component = new Vue({
|
||||
el: element,
|
||||
components: {
|
||||
@ -51,7 +57,14 @@ export default function StackedPlotViewProvider(openmct) {
|
||||
domainObject,
|
||||
composition: openmct.composition.get(domainObject)
|
||||
},
|
||||
template: '<stacked-plot></stacked-plot>'
|
||||
data() {
|
||||
return {
|
||||
options: {
|
||||
compact: isCompact
|
||||
}
|
||||
};
|
||||
},
|
||||
template: '<stacked-plot :options="options"></stacked-plot>'
|
||||
});
|
||||
},
|
||||
destroy: function () {
|
||||
|
@ -60,11 +60,12 @@ define([
|
||||
'./nonEditableFolder/plugin',
|
||||
'./persistence/couch/plugin',
|
||||
'./defaultRootName/plugin',
|
||||
'./timeline/plugin',
|
||||
'./plan/plugin',
|
||||
'./viewDatumAction/plugin',
|
||||
'./interceptors/plugin',
|
||||
'./performanceIndicator/plugin',
|
||||
'./CouchDBSearchFolder/plugin'
|
||||
'./CouchDBSearchFolder/plugin',
|
||||
'./timeline/plugin'
|
||||
], function (
|
||||
_,
|
||||
UTCTimeSystem,
|
||||
@ -105,11 +106,12 @@ define([
|
||||
NonEditableFolder,
|
||||
CouchDBPlugin,
|
||||
DefaultRootName,
|
||||
Timeline,
|
||||
PlanLayout,
|
||||
ViewDatumAction,
|
||||
ObjectInterceptors,
|
||||
PerformanceIndicator,
|
||||
CouchDBSearchFolder
|
||||
CouchDBSearchFolder,
|
||||
Timeline
|
||||
) {
|
||||
const bundleMap = {
|
||||
LocalStorage: 'platform/persistence/local',
|
||||
@ -204,11 +206,12 @@ define([
|
||||
plugins.NonEditableFolder = NonEditableFolder.default;
|
||||
plugins.ISOTimeFormat = ISOTimeFormat.default;
|
||||
plugins.DefaultRootName = DefaultRootName.default;
|
||||
plugins.Timeline = Timeline.default;
|
||||
plugins.PlanLayout = PlanLayout.default;
|
||||
plugins.ViewDatumAction = ViewDatumAction.default;
|
||||
plugins.ObjectInterceptors = ObjectInterceptors.default;
|
||||
plugins.PerformanceIndicator = PerformanceIndicator.default;
|
||||
plugins.CouchDBSearchFolder = CouchDBSearchFolder.default;
|
||||
plugins.Timeline = Timeline.default;
|
||||
|
||||
return plugins;
|
||||
});
|
||||
|
@ -1,454 +0,0 @@
|
||||
<template>
|
||||
<div ref="axisHolder"
|
||||
class="c-timeline-plan"
|
||||
>
|
||||
<div class="nowMarker"><span class="icon-arrow-down"></span></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import * as d3Selection from 'd3-selection';
|
||||
import * as d3Axis from 'd3-axis';
|
||||
import * as d3Scale from 'd3-scale';
|
||||
import utcMultiTimeFormat from "@/plugins/timeConductor/utcMultiTimeFormat";
|
||||
|
||||
//TODO: UI direction needed for the following property values
|
||||
const PADDING = 1;
|
||||
const OUTER_TEXT_PADDING = 12;
|
||||
const INNER_TEXT_PADDING = 17;
|
||||
const TEXT_LEFT_PADDING = 5;
|
||||
const ROW_PADDING = 12;
|
||||
// const DEFAULT_DURATION_FORMATTER = 'duration';
|
||||
const RESIZE_POLL_INTERVAL = 200;
|
||||
const PIXELS_PER_TICK = 100;
|
||||
const PIXELS_PER_TICK_WIDE = 200;
|
||||
const ROW_HEIGHT = 30;
|
||||
const LINE_HEIGHT = 12;
|
||||
const MAX_TEXT_WIDTH = 300;
|
||||
const TIMELINE_HEIGHT = 30;
|
||||
//This offset needs to be re-considered
|
||||
const TIMELINE_OFFSET_HEIGHT = 70;
|
||||
const GROUP_OFFSET = 100;
|
||||
|
||||
export default {
|
||||
inject: ['openmct', 'domainObject'],
|
||||
props: {
|
||||
"renderingEngine": {
|
||||
type: String,
|
||||
default() {
|
||||
return 'canvas';
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.validateJSON(this.domainObject.selectFile.body);
|
||||
if (this.renderingEngine === 'svg') {
|
||||
this.useSVG = true;
|
||||
}
|
||||
|
||||
this.container = d3Selection.select(this.$refs.axisHolder);
|
||||
this.svgElement = this.container.append("svg:svg");
|
||||
// draw x axis with labels. CSS is used to position them.
|
||||
this.axisElement = this.svgElement.append("g")
|
||||
.attr("class", "axis");
|
||||
this.xAxis = d3Axis.axisTop();
|
||||
|
||||
this.canvas = this.container.append('canvas').node();
|
||||
this.canvasContext = this.canvas.getContext('2d');
|
||||
|
||||
this.setDimensions();
|
||||
this.updateViewBounds();
|
||||
this.openmct.time.on("timeSystem", this.setScaleAndPlotActivities);
|
||||
this.openmct.time.on("bounds", this.updateViewBounds);
|
||||
this.resizeTimer = setInterval(this.resize, RESIZE_POLL_INTERVAL);
|
||||
if (this.openmct.objects.supportsMutation(this.domainObject.identifier)) {
|
||||
this.openmct.objects.getMutable(this.domainObject.identifier)
|
||||
.then(this.observeForChanges);
|
||||
}
|
||||
|
||||
this.unlisten = this.openmct.objects.observe(this.domainObject, '*', this.observeForChanges);
|
||||
},
|
||||
destroyed() {
|
||||
clearInterval(this.resizeTimer);
|
||||
this.openmct.time.off("timeSystem", this.setScaleAndPlotActivities);
|
||||
this.openmct.time.off("bounds", this.updateViewBounds);
|
||||
if (this.unlisten) {
|
||||
this.unlisten();
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
observeForChanges(mutatedObject) {
|
||||
if (mutatedObject.selectFile) {
|
||||
this.validateJSON(mutatedObject.selectFile.body);
|
||||
this.setScaleAndPlotActivities();
|
||||
}
|
||||
},
|
||||
resize() {
|
||||
if (this.$refs.axisHolder.clientWidth !== this.width) {
|
||||
this.setDimensions();
|
||||
this.updateViewBounds();
|
||||
}
|
||||
},
|
||||
validateJSON(jsonString) {
|
||||
try {
|
||||
this.json = JSON.parse(jsonString);
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
updateViewBounds() {
|
||||
this.viewBounds = this.openmct.time.bounds();
|
||||
// this.viewBounds.end = this.viewBounds.end + (30 * 60 * 1000);
|
||||
this.setScaleAndPlotActivities();
|
||||
},
|
||||
updateNowMarker() {
|
||||
if (this.openmct.time.clock() === undefined) {
|
||||
let nowMarker = document.querySelector('.nowMarker');
|
||||
if (nowMarker) {
|
||||
nowMarker.parentNode.removeChild(nowMarker);
|
||||
}
|
||||
} else {
|
||||
let nowMarker = document.querySelector('.nowMarker');
|
||||
if (nowMarker) {
|
||||
const svgEl = d3Selection.select(this.svgElement).node();
|
||||
const height = this.useSVG ? svgEl.style('height') : this.canvas.height + 'px';
|
||||
nowMarker.style.height = height;
|
||||
const now = this.xScale(Date.now());
|
||||
nowMarker.style.left = now + GROUP_OFFSET + 'px';
|
||||
}
|
||||
}
|
||||
},
|
||||
setScaleAndPlotActivities() {
|
||||
this.setScale();
|
||||
this.clearPreviousActivities();
|
||||
if (this.xScale) {
|
||||
this.calculatePlanLayout();
|
||||
this.drawPlan();
|
||||
this.updateNowMarker();
|
||||
}
|
||||
},
|
||||
clearPreviousActivities() {
|
||||
if (this.useSVG) {
|
||||
d3Selection.selectAll("svg > :not(g)").remove();
|
||||
} else {
|
||||
this.canvasContext.clearRect(0, 0, this.canvas.width, this.canvas.height);
|
||||
}
|
||||
},
|
||||
setDimensions() {
|
||||
const axisHolder = this.$refs.axisHolder;
|
||||
const rect = axisHolder.getBoundingClientRect();
|
||||
this.left = Math.round(rect.left);
|
||||
this.top = Math.round(rect.top);
|
||||
this.width = axisHolder.clientWidth;
|
||||
this.offsetWidth = this.width - GROUP_OFFSET;
|
||||
|
||||
const axisHolderParent = this.$parent.$refs.planHolder;
|
||||
this.height = Math.round(axisHolderParent.getBoundingClientRect().height);
|
||||
|
||||
if (this.useSVG) {
|
||||
this.svgElement.attr("width", this.width);
|
||||
this.svgElement.attr("height", this.height);
|
||||
} else {
|
||||
this.svgElement.attr("height", 50);
|
||||
this.canvas.width = this.width;
|
||||
this.canvas.height = this.height;
|
||||
}
|
||||
|
||||
this.canvasContext.font = "normal normal 12px sans-serif";
|
||||
},
|
||||
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.offsetWidth - PADDING * 2]);
|
||||
|
||||
this.xAxis.scale(this.xScale);
|
||||
this.xAxis.tickFormat(utcMultiTimeFormat);
|
||||
|
||||
this.axisElement.call(this.xAxis);
|
||||
|
||||
if (this.width > 1800) {
|
||||
this.xAxis.ticks(this.offsetWidth / PIXELS_PER_TICK_WIDE);
|
||||
} else {
|
||||
this.xAxis.ticks(this.offsetWidth / PIXELS_PER_TICK);
|
||||
}
|
||||
},
|
||||
isActivityInBounds(activity) {
|
||||
return (activity.start < this.viewBounds.end) && (activity.end > this.viewBounds.start);
|
||||
},
|
||||
getTextWidth(name) {
|
||||
// canvasContext.font = font;
|
||||
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, minimumActivityRow = 0) {
|
||||
let currentRow;
|
||||
let sortedActivityRows = Object.keys(this.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 (row >= minimumActivityRow && getOverlap(this.activitiesByRow[row])) {
|
||||
currentRow = row;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (currentRow === undefined && sortedActivityRows.length) {
|
||||
let row = Math.max(parseInt(sortedActivityRows[sortedActivityRows.length - 1], 10), minimumActivityRow);
|
||||
currentRow = row + ROW_HEIGHT + ROW_PADDING;
|
||||
}
|
||||
|
||||
return (currentRow || minimumActivityRow);
|
||||
},
|
||||
calculatePlanLayout() {
|
||||
this.activitiesByRow = {};
|
||||
|
||||
let currentRow = 0;
|
||||
|
||||
let groups = Object.keys(this.json);
|
||||
groups.forEach((key, index) => {
|
||||
let activities = this.json[key];
|
||||
//set the new group's first row. It should be greater than the largest row of the last group
|
||||
let sortedActivityRows = Object.keys(this.activitiesByRow).sort(this.sortFn);
|
||||
const groupRowStart = sortedActivityRows.length ? parseInt(sortedActivityRows[sortedActivityRows.length - 1], 10) + 1 : 0;
|
||||
let newGroup = true;
|
||||
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 : (rectX + rectWidth)) + TEXT_LEFT_PADDING;
|
||||
|
||||
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, groupRowStart);
|
||||
} else {
|
||||
currentRow = this.getRowForActivity(rectX, textWidth, groupRowStart);
|
||||
}
|
||||
|
||||
let textY = parseInt(currentRow, 10) + (activityNameFitsRect ? INNER_TEXT_PADDING : OUTER_TEXT_PADDING);
|
||||
|
||||
if (!this.activitiesByRow[currentRow]) {
|
||||
this.activitiesByRow[currentRow] = [];
|
||||
}
|
||||
|
||||
this.activitiesByRow[currentRow].push({
|
||||
heading: newGroup ? key : '',
|
||||
activity: {
|
||||
color: activity.color,
|
||||
textColor: activity.textColor
|
||||
},
|
||||
textLines: textLines,
|
||||
textStart: textStart,
|
||||
textY: textY,
|
||||
start: rectX,
|
||||
end: activityNameFitsRect ? rectX + rectWidth : textStart + textWidth,
|
||||
rectWidth: rectWidth
|
||||
});
|
||||
newGroup = false;
|
||||
}
|
||||
});
|
||||
});
|
||||
},
|
||||
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];
|
||||
},
|
||||
getGroupHeading(row) {
|
||||
let groupHeadingRow;
|
||||
let groupHeadingBorder;
|
||||
|
||||
if (row) {
|
||||
groupHeadingBorder = row + ROW_PADDING + OUTER_TEXT_PADDING;
|
||||
groupHeadingRow = groupHeadingBorder + OUTER_TEXT_PADDING;
|
||||
} else {
|
||||
groupHeadingRow = TIMELINE_HEIGHT + OUTER_TEXT_PADDING;
|
||||
}
|
||||
|
||||
return {
|
||||
groupHeadingRow,
|
||||
groupHeadingBorder
|
||||
};
|
||||
},
|
||||
getPlanHeight(activityRows) {
|
||||
return parseInt(activityRows[activityRows.length - 1], 10) + TIMELINE_OFFSET_HEIGHT;
|
||||
},
|
||||
drawPlan() {
|
||||
const activityRows = Object.keys(this.activitiesByRow);
|
||||
if (activityRows.length) {
|
||||
|
||||
let planHeight = this.getPlanHeight(activityRows);
|
||||
planHeight = Math.max(this.height, planHeight);
|
||||
if (this.useSVG) {
|
||||
this.svgElement.attr("height", planHeight);
|
||||
} else {
|
||||
// This needs to happen before we draw on the canvas or the canvas will get wiped out when height is set
|
||||
this.canvas.height = planHeight;
|
||||
}
|
||||
|
||||
activityRows.forEach((key) => {
|
||||
const items = this.activitiesByRow[key];
|
||||
const row = parseInt(key, 10);
|
||||
items.forEach((item) => {
|
||||
|
||||
//TODO: Don't draw the left-border of the rectangle if the activity started before viewBounds.start
|
||||
if (this.useSVG) {
|
||||
this.plotSVG(item, row);
|
||||
} else {
|
||||
this.plotCanvas(item, row);
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
});
|
||||
}
|
||||
},
|
||||
plotSVG(item, row) {
|
||||
const headingText = item.heading;
|
||||
const { groupHeadingRow, groupHeadingBorder } = this.getGroupHeading(row);
|
||||
|
||||
if (headingText) {
|
||||
if (groupHeadingBorder) {
|
||||
this.svgElement.append("line")
|
||||
.attr("class", "activity")
|
||||
.attr("x1", 0)
|
||||
.attr("y1", groupHeadingBorder)
|
||||
.attr("x2", this.width)
|
||||
.attr("y2", groupHeadingBorder)
|
||||
.attr('stroke', "white");
|
||||
}
|
||||
|
||||
this.svgElement.append("text").text(headingText)
|
||||
.attr("class", "activity")
|
||||
.attr("x", 0)
|
||||
.attr("y", groupHeadingRow)
|
||||
.attr('fill', "white");
|
||||
}
|
||||
|
||||
const activity = item.activity;
|
||||
const rectY = row + TIMELINE_HEIGHT;
|
||||
this.svgElement.append("rect")
|
||||
.attr("class", "activity")
|
||||
.attr("x", item.start + GROUP_OFFSET)
|
||||
.attr("y", rectY + TIMELINE_HEIGHT)
|
||||
.attr("width", item.rectWidth)
|
||||
.attr("height", ROW_HEIGHT)
|
||||
.attr('fill', activity.color)
|
||||
.attr('stroke', "lightgray");
|
||||
|
||||
item.textLines.forEach((line, index) => {
|
||||
this.svgElement.append("text").text(line)
|
||||
.attr("class", "activity")
|
||||
.attr("x", item.textStart + GROUP_OFFSET)
|
||||
.attr("y", item.textY + TIMELINE_HEIGHT + (index * LINE_HEIGHT))
|
||||
.attr('fill', activity.textColor);
|
||||
});
|
||||
//TODO: Ending border
|
||||
},
|
||||
plotCanvas(item, row) {
|
||||
const headingText = item.heading;
|
||||
const { groupHeadingRow, groupHeadingBorder } = this.getGroupHeading(row);
|
||||
|
||||
if (headingText) {
|
||||
if (groupHeadingBorder) {
|
||||
this.canvasContext.strokeStyle = "white";
|
||||
this.canvasContext.beginPath();
|
||||
this.canvasContext.moveTo(0, groupHeadingBorder);
|
||||
this.canvasContext.lineTo(this.width, groupHeadingBorder);
|
||||
this.canvasContext.stroke();
|
||||
}
|
||||
|
||||
this.canvasContext.fillStyle = "white";
|
||||
this.canvasContext.fillText(headingText, 0, groupHeadingRow);
|
||||
}
|
||||
|
||||
const activity = item.activity;
|
||||
const rectX = item.start;
|
||||
const rectY = row + TIMELINE_HEIGHT;
|
||||
const rectWidth = item.rectWidth;
|
||||
this.canvasContext.fillStyle = activity.color;
|
||||
this.canvasContext.strokeStyle = "lightgray";
|
||||
this.canvasContext.fillRect(rectX + GROUP_OFFSET, rectY, rectWidth, ROW_HEIGHT);
|
||||
this.canvasContext.strokeRect(rectX + GROUP_OFFSET, rectY, rectWidth, ROW_HEIGHT);
|
||||
|
||||
this.canvasContext.fillStyle = activity.textColor;
|
||||
|
||||
item.textLines.forEach((line, index) => {
|
||||
this.canvasContext.fillText(line, item.textStart + GROUP_OFFSET, item.textY + TIMELINE_HEIGHT + (index * LINE_HEIGHT));
|
||||
});
|
||||
//TODO: Ending border
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
@ -21,25 +21,160 @@
|
||||
*****************************************************************************/
|
||||
|
||||
<template>
|
||||
<div ref="planHolder"
|
||||
class="c-timeline"
|
||||
<div ref="timelineHolder"
|
||||
class="c-timeline-holder"
|
||||
>
|
||||
<plan :rendering-engine="'canvas'" />
|
||||
<div class="c-timeline">
|
||||
<div v-for="timeSystemItem in timeSystems"
|
||||
:key="timeSystemItem.timeSystem.key"
|
||||
class="u-contents"
|
||||
>
|
||||
<swim-lane>
|
||||
<template slot="label">
|
||||
{{ timeSystemItem.timeSystem.name }}
|
||||
</template>
|
||||
<template slot="object">
|
||||
<timeline-axis :bounds="timeSystemItem.bounds"
|
||||
:time-system="timeSystemItem.timeSystem"
|
||||
:content-height="height"
|
||||
:rendering-engine="'svg'"
|
||||
/>
|
||||
</template>
|
||||
|
||||
</swim-lane>
|
||||
</div>
|
||||
|
||||
<div ref="contentHolder"
|
||||
class="u-contents c-timeline__objects c-timeline__content-holder"
|
||||
>
|
||||
<div
|
||||
v-for="item in items"
|
||||
:key="item.keyString"
|
||||
class="u-contents c-timeline__content"
|
||||
>
|
||||
<swim-lane :icon-class="item.type.definition.cssClass"
|
||||
:min-height="item.height"
|
||||
:show-ucontents="item.domainObject.type === 'plan'"
|
||||
:span-rows="item.domainObject.type === 'plan'"
|
||||
>
|
||||
<template slot="label">
|
||||
{{ item.domainObject.name }}
|
||||
</template>
|
||||
<object-view
|
||||
slot="object"
|
||||
class="u-contents"
|
||||
:default-object="item.domainObject"
|
||||
:object-view-key="item.viewKey"
|
||||
:object-path="item.objectPath"
|
||||
/>
|
||||
</swim-lane>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Plan from './Plan.vue';
|
||||
import ObjectView from '@/ui/components/ObjectView.vue';
|
||||
import TimelineAxis from '../../ui/components/TimeSystemAxis.vue';
|
||||
import SwimLane from "@/ui/components/swim-lane/SwimLane.vue";
|
||||
|
||||
const unknownObjectType = {
|
||||
definition: {
|
||||
cssClass: 'icon-object-unknown',
|
||||
name: 'Unknown Type'
|
||||
}
|
||||
};
|
||||
|
||||
function getViewKey(domainObject, openmct) {
|
||||
let viewKey = '';
|
||||
const plotView = openmct.objectViews.get(domainObject).find((view) => {
|
||||
return view.key.startsWith('plot-') && view.key !== 'plot-single';
|
||||
});
|
||||
if (plotView) {
|
||||
viewKey = plotView.key;
|
||||
}
|
||||
|
||||
return viewKey;
|
||||
}
|
||||
|
||||
export default {
|
||||
components: {
|
||||
Plan
|
||||
ObjectView,
|
||||
TimelineAxis,
|
||||
SwimLane
|
||||
},
|
||||
inject: ['openmct', 'domainObject'],
|
||||
inject: ['openmct', 'domainObject', 'composition', 'objectPath'],
|
||||
data() {
|
||||
return {
|
||||
plans: []
|
||||
items: [],
|
||||
timeSystems: [],
|
||||
height: 0
|
||||
};
|
||||
},
|
||||
beforeDestroy() {
|
||||
this.composition.off('add', this.addItem);
|
||||
this.composition.off('remove', this.removeItem);
|
||||
this.composition.off('reorder', this.reorder);
|
||||
},
|
||||
mounted() {
|
||||
if (this.composition) {
|
||||
this.composition.on('add', this.addItem);
|
||||
this.composition.on('remove', this.removeItem);
|
||||
this.composition.on('reorder', this.reorder);
|
||||
this.composition.load();
|
||||
}
|
||||
|
||||
this.getTimeSystems();
|
||||
},
|
||||
methods: {
|
||||
addItem(domainObject) {
|
||||
let type = this.openmct.types.get(domainObject.type) || unknownObjectType;
|
||||
let keyString = this.openmct.objects.makeKeyString(domainObject.identifier);
|
||||
let objectPath = [domainObject].concat(this.objectPath.slice());
|
||||
let viewKey = getViewKey(domainObject, this.openmct);
|
||||
|
||||
let height = domainObject.type === 'telemetry.plot.stacked' ? `${domainObject.composition.length * 100}px` : '100px';
|
||||
let item = {
|
||||
domainObject,
|
||||
objectPath,
|
||||
type,
|
||||
keyString,
|
||||
viewKey,
|
||||
height
|
||||
};
|
||||
|
||||
this.items.push(item);
|
||||
this.updateContentHeight();
|
||||
},
|
||||
removeItem(identifier) {
|
||||
let index = this.items.findIndex(item => this.openmct.objects.areIdsEqual(identifier, item.domainObject.identifier));
|
||||
this.items.splice(index, 1);
|
||||
},
|
||||
reorder(reorderPlan) {
|
||||
let oldItems = this.items.slice();
|
||||
reorderPlan.forEach((reorderEvent) => {
|
||||
this.$set(this.items, reorderEvent.newIndex, oldItems[reorderEvent.oldIndex]);
|
||||
});
|
||||
},
|
||||
updateContentHeight() {
|
||||
this.height = Math.round(this.$refs.contentHolder.getBoundingClientRect().height);
|
||||
},
|
||||
getTimeSystems() {
|
||||
const timeSystems = this.openmct.time.getAllTimeSystems();
|
||||
timeSystems.forEach(timeSystem => {
|
||||
this.timeSystems.push({
|
||||
timeSystem,
|
||||
bounds: this.getBoundsForTimeSystem(timeSystem)
|
||||
});
|
||||
});
|
||||
},
|
||||
getBoundsForTimeSystem(timeSystem) {
|
||||
const currentBounds = this.openmct.time.bounds();
|
||||
|
||||
//TODO: Some kind of translation via an offset? of current bounds to target timeSystem
|
||||
return currentBounds;
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
@ -26,18 +26,18 @@ import Vue from 'vue';
|
||||
export default function TimelineViewProvider(openmct) {
|
||||
|
||||
return {
|
||||
key: 'timeline.view',
|
||||
name: 'Timeline',
|
||||
key: 'time-strip.view',
|
||||
name: 'TimeStrip',
|
||||
cssClass: 'icon-clock',
|
||||
canView(domainObject) {
|
||||
return domainObject.type === 'plan';
|
||||
return domainObject.type === 'time-strip';
|
||||
},
|
||||
|
||||
canEdit(domainObject) {
|
||||
return domainObject.type === 'plan';
|
||||
return domainObject.type === 'time-strip';
|
||||
},
|
||||
|
||||
view: function (domainObject) {
|
||||
view: function (domainObject, objectPath) {
|
||||
let component;
|
||||
|
||||
return {
|
||||
@ -49,7 +49,9 @@ export default function TimelineViewProvider(openmct) {
|
||||
},
|
||||
provide: {
|
||||
openmct,
|
||||
domainObject
|
||||
domainObject,
|
||||
composition: openmct.composition.get(domainObject),
|
||||
objectPath
|
||||
},
|
||||
template: '<timeline-view-layout></timeline-view-layout>'
|
||||
});
|
||||
|
@ -20,27 +20,18 @@
|
||||
* at runtime from the About dialog for additional information.
|
||||
*****************************************************************************/
|
||||
|
||||
import TimelineViewProvider from './TimelineViewProvider';
|
||||
import TimelineViewProvider from '../timeline/TimelineViewProvider';
|
||||
|
||||
export default function () {
|
||||
return function install(openmct) {
|
||||
openmct.types.addType('plan', {
|
||||
name: 'Plan',
|
||||
key: 'plan',
|
||||
openmct.types.addType('time-strip', {
|
||||
name: 'Time Strip',
|
||||
key: 'time-strip',
|
||||
description: 'An activity timeline',
|
||||
creatable: true,
|
||||
cssClass: 'icon-timeline',
|
||||
form: [
|
||||
{
|
||||
name: 'Upload Plan (JSON File)',
|
||||
key: 'selectFile',
|
||||
control: 'file-input',
|
||||
required: true,
|
||||
text: 'Select File',
|
||||
type: 'application/json'
|
||||
}
|
||||
],
|
||||
initialize: function (domainObject) {
|
||||
domainObject.composition = [];
|
||||
}
|
||||
});
|
||||
openmct.objectViews.addProvider(new TimelineViewProvider(openmct));
|
||||
|
@ -22,11 +22,9 @@
|
||||
|
||||
import { createOpenMct, resetApplicationState } from "utils/testing";
|
||||
import TimelinePlugin from "./plugin";
|
||||
import Vue from 'vue';
|
||||
import TimelineViewLayout from "./TimelineViewLayout.vue";
|
||||
|
||||
describe('the plugin', function () {
|
||||
let planDefinition;
|
||||
let objectDef;
|
||||
let element;
|
||||
let child;
|
||||
let openmct;
|
||||
@ -39,7 +37,7 @@ describe('the plugin', function () {
|
||||
openmct = createOpenMct();
|
||||
openmct.install(new TimelinePlugin());
|
||||
|
||||
planDefinition = openmct.types.get('plan').definition;
|
||||
objectDef = openmct.types.get('time-strip').definition;
|
||||
|
||||
element = document.createElement('div');
|
||||
element.style.width = '640px';
|
||||
@ -62,148 +60,33 @@ describe('the plugin', function () {
|
||||
return resetApplicationState(openmct);
|
||||
});
|
||||
|
||||
let mockPlanObject = {
|
||||
name: 'Plan',
|
||||
key: 'plan',
|
||||
let mockObject = {
|
||||
name: 'Time Strip',
|
||||
key: 'time-strip',
|
||||
creatable: true
|
||||
};
|
||||
|
||||
it('defines a plan object type with the correct key', () => {
|
||||
expect(planDefinition.key).toEqual(mockPlanObject.key);
|
||||
it('defines a time-strip object type with the correct key', () => {
|
||||
expect(objectDef.key).toEqual(mockObject.key);
|
||||
});
|
||||
|
||||
describe('the plan object', () => {
|
||||
describe('the time-strip object', () => {
|
||||
|
||||
it('is creatable', () => {
|
||||
expect(planDefinition.creatable).toEqual(mockPlanObject.creatable);
|
||||
expect(objectDef.creatable).toEqual(mockObject.creatable);
|
||||
});
|
||||
|
||||
it('provides a timeline view', () => {
|
||||
const testViewObject = {
|
||||
id: "test-object",
|
||||
type: "plan"
|
||||
type: "time-strip"
|
||||
};
|
||||
|
||||
const applicableViews = openmct.objectViews.get(testViewObject);
|
||||
let timelineView = applicableViews.find((viewProvider) => viewProvider.key === 'timeline.view');
|
||||
let timelineView = applicableViews.find((viewProvider) => viewProvider.key === 'time-strip.view');
|
||||
expect(timelineView).toBeDefined();
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
describe('the timeline view displays activities', () => {
|
||||
let planDomainObject;
|
||||
let component;
|
||||
let planViewComponent;
|
||||
|
||||
beforeEach((done) => {
|
||||
planDomainObject = {
|
||||
identifier: {
|
||||
key: 'test-object',
|
||||
namespace: ''
|
||||
},
|
||||
type: 'plan',
|
||||
id: "test-object",
|
||||
selectFile: {
|
||||
body: JSON.stringify({
|
||||
"TEST-GROUP": [
|
||||
{
|
||||
"name": "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua",
|
||||
"start": 1597170002854,
|
||||
"end": 1597171032854,
|
||||
"type": "TEST-GROUP",
|
||||
"color": "fuchsia",
|
||||
"textColor": "black"
|
||||
},
|
||||
{
|
||||
"name": "Sed ut perspiciatis",
|
||||
"start": 1597171132854,
|
||||
"end": 1597171232854,
|
||||
"type": "TEST-GROUP",
|
||||
"color": "fuchsia",
|
||||
"textColor": "black"
|
||||
}
|
||||
]
|
||||
})
|
||||
}
|
||||
};
|
||||
|
||||
let viewContainer = document.createElement('div');
|
||||
child.append(viewContainer);
|
||||
component = new Vue({
|
||||
el: viewContainer,
|
||||
components: {
|
||||
TimelineViewLayout
|
||||
},
|
||||
provide: {
|
||||
openmct: openmct,
|
||||
domainObject: planDomainObject
|
||||
},
|
||||
template: '<timeline-view-layout/>'
|
||||
});
|
||||
|
||||
return Vue.nextTick().then(() => {
|
||||
planViewComponent = component.$root.$children[0].$children[0];
|
||||
setTimeout(() => {
|
||||
clearInterval(planViewComponent.resizeTimer);
|
||||
//TODO: this is a hack to ensure the canvas has a width - maybe there's a better way to set the width of the plan div
|
||||
planViewComponent.width = 1200;
|
||||
planViewComponent.setScaleAndPlotActivities();
|
||||
done();
|
||||
}, 300);
|
||||
});
|
||||
});
|
||||
|
||||
it('loads activities into the view', () => {
|
||||
expect(planViewComponent.json).toBeDefined();
|
||||
expect(planViewComponent.json["TEST-GROUP"].length).toEqual(2);
|
||||
});
|
||||
|
||||
it('loads a time axis into the view', () => {
|
||||
let ticks = planViewComponent.axisElement.node().querySelectorAll('g.tick');
|
||||
expect(ticks.length).toEqual(11);
|
||||
});
|
||||
|
||||
it('calculates the activity layout', () => {
|
||||
const expectedActivitiesByRow = {
|
||||
"0": [
|
||||
{
|
||||
"heading": "TEST-GROUP",
|
||||
"activity": {
|
||||
"color": "fuchsia",
|
||||
"textColor": "black"
|
||||
},
|
||||
"textLines": [
|
||||
"Lorem ipsum dolor sit amet, consectetur adipiscing elit, ",
|
||||
"sed sed do eiusmod tempor incididunt ut labore et "
|
||||
],
|
||||
"textStart": -47.51342439943476,
|
||||
"textY": 12,
|
||||
"start": -47.51625058878945,
|
||||
"end": 204.97315120113046,
|
||||
"rectWidth": -4.9971738106453145
|
||||
}
|
||||
],
|
||||
"42": [
|
||||
{
|
||||
"heading": "",
|
||||
"activity": {
|
||||
"color": "fuchsia",
|
||||
"textColor": "black"
|
||||
},
|
||||
"textLines": [
|
||||
"Sed ut perspiciatis "
|
||||
],
|
||||
"textStart": -48.483749411210546,
|
||||
"textY": 54,
|
||||
"start": -52.99858690532266,
|
||||
"end": 9.032501177578908,
|
||||
"rectWidth": -0.48516250588788523
|
||||
}
|
||||
]
|
||||
};
|
||||
expect(Object.keys(planViewComponent.activitiesByRow)).toEqual(Object.keys(expectedActivitiesByRow));
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
|
7
src/plugins/timeline/timeline.scss
Normal file
7
src/plugins/timeline/timeline.scss
Normal file
@ -0,0 +1,7 @@
|
||||
.c-timeline-holder {
|
||||
@include abs();
|
||||
}
|
||||
|
||||
.c-timeline {
|
||||
|
||||
}
|
@ -27,13 +27,16 @@
|
||||
@import "../plugins/timeConductor/conductor-mode.scss";
|
||||
@import "../plugins/timeConductor/conductor-mode-icon.scss";
|
||||
@import "../plugins/timeConductor/date-picker.scss";
|
||||
@import "../plugins/timeline/timeline-axis.scss";
|
||||
@import "../plugins/timeline/timeline.scss";
|
||||
@import "../plugins/plan/plan";
|
||||
@import "../plugins/viewDatumAction/components/metadata-list.scss";
|
||||
@import "../ui/components/object-frame.scss";
|
||||
@import "../ui/components/object-label.scss";
|
||||
@import "../ui/components/progress-bar.scss";
|
||||
@import "../ui/components/search.scss";
|
||||
@import "../ui/components/swim-lane/swim-lane.scss";
|
||||
@import "../ui/components/toggle-switch.scss";
|
||||
@import "../ui/components/timesystem-axis.scss";
|
||||
@import "../ui/inspector/elements.scss";
|
||||
@import "../ui/inspector/inspector.scss";
|
||||
@import "../ui/inspector/location.scss";
|
||||
|
@ -28,6 +28,10 @@ export default {
|
||||
layoutFont: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
objectViewKey: {
|
||||
type: String,
|
||||
default: ''
|
||||
}
|
||||
},
|
||||
data() {
|
||||
@ -303,8 +307,17 @@ export default {
|
||||
event.stopPropagation();
|
||||
}
|
||||
},
|
||||
getViewKey() {
|
||||
let viewKey = this.viewKey;
|
||||
if (this.objectViewKey) {
|
||||
viewKey = this.objectViewKey;
|
||||
}
|
||||
|
||||
return viewKey;
|
||||
},
|
||||
getViewProvider() {
|
||||
let provider = this.openmct.objectViews.getByProviderKey(this.viewKey);
|
||||
|
||||
let provider = this.openmct.objectViews.getByProviderKey(this.getViewKey());
|
||||
|
||||
if (!provider) {
|
||||
provider = this.openmct.objectViews.get(this.domainObject)[0];
|
||||
|
166
src/ui/components/TimeSystemAxis.vue
Normal file
166
src/ui/components/TimeSystemAxis.vue
Normal file
@ -0,0 +1,166 @@
|
||||
<template>
|
||||
<div ref="axisHolder"
|
||||
class="c-timesystem-axis"
|
||||
>
|
||||
<div class="nowMarker"><span class="icon-arrow-down"></span></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import * as d3Selection from 'd3-selection';
|
||||
import * as d3Axis from 'd3-axis';
|
||||
import * as d3Scale from 'd3-scale';
|
||||
import utcMultiTimeFormat from '@/plugins/timeConductor/utcMultiTimeFormat';
|
||||
|
||||
//TODO: UI direction needed for the following property values
|
||||
const PADDING = 1;
|
||||
const RESIZE_POLL_INTERVAL = 200;
|
||||
const PIXELS_PER_TICK = 100;
|
||||
const PIXELS_PER_TICK_WIDE = 200;
|
||||
//This offset needs to be re-considered
|
||||
|
||||
export default {
|
||||
inject: ['openmct', 'domainObject'],
|
||||
props: {
|
||||
bounds: {
|
||||
type: Object,
|
||||
default() {
|
||||
return {};
|
||||
}
|
||||
},
|
||||
timeSystem: {
|
||||
type: Object,
|
||||
default() {
|
||||
return {};
|
||||
}
|
||||
},
|
||||
contentHeight: {
|
||||
type: Number,
|
||||
default() {
|
||||
return 0;
|
||||
}
|
||||
},
|
||||
renderingEngine: {
|
||||
type: String,
|
||||
default() {
|
||||
return 'svg';
|
||||
}
|
||||
},
|
||||
offset: {
|
||||
type: Number,
|
||||
default() {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
bounds(newBounds) {
|
||||
this.drawAxis(newBounds, this.timeSystem);
|
||||
},
|
||||
timeSystem(newTimeSystem) {
|
||||
this.drawAxis(this.bounds, newTimeSystem);
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
if (this.renderingEngine === 'svg') {
|
||||
this.useSVG = true;
|
||||
}
|
||||
|
||||
this.container = d3Selection.select(this.$refs.axisHolder);
|
||||
this.svgElement = this.container.append("svg:svg");
|
||||
// draw x axis with labels. CSS is used to position them.
|
||||
this.axisElement = this.svgElement.append("g")
|
||||
.attr("class", "axis")
|
||||
.attr('font-size', '1.3em')
|
||||
.attr("transform", "translate(0,20)");
|
||||
|
||||
this.setDimensions();
|
||||
this.drawAxis(this.bounds, this.timeSystem);
|
||||
this.resizeTimer = setInterval(this.resize, RESIZE_POLL_INTERVAL);
|
||||
},
|
||||
destroyed() {
|
||||
clearInterval(this.resizeTimer);
|
||||
},
|
||||
methods: {
|
||||
resize() {
|
||||
if (this.$refs.axisHolder.clientWidth !== this.width) {
|
||||
this.setDimensions();
|
||||
this.drawAxis(this.bounds, this.timeSystem);
|
||||
this.updateNowMarker();
|
||||
}
|
||||
},
|
||||
updateNowMarker() {
|
||||
if (this.openmct.time.clock() === undefined) {
|
||||
let nowMarker = document.querySelector('.nowMarker');
|
||||
if (nowMarker) {
|
||||
nowMarker.parentNode.removeChild(nowMarker);
|
||||
}
|
||||
} else {
|
||||
let nowMarker = document.querySelector('.nowMarker');
|
||||
if (nowMarker) {
|
||||
const svgEl = d3Selection.select(this.svgElement).node();
|
||||
let height = svgEl.style('height').replace('px', '');
|
||||
height = Number(height) + this.contentHeight;
|
||||
nowMarker.style.height = height + 'px';
|
||||
const now = this.xScale(Date.now());
|
||||
nowMarker.style.left = now + this.offset + 'px';
|
||||
}
|
||||
}
|
||||
},
|
||||
setDimensions() {
|
||||
const axisHolder = this.$refs.axisHolder;
|
||||
this.width = axisHolder.clientWidth;
|
||||
this.offsetWidth = this.width - this.offset;
|
||||
|
||||
this.height = Math.round(axisHolder.getBoundingClientRect().height);
|
||||
|
||||
if (this.useSVG) {
|
||||
this.svgElement.attr("width", this.width);
|
||||
this.svgElement.attr("height", this.height);
|
||||
} else {
|
||||
this.svgElement.attr("height", 50);
|
||||
}
|
||||
},
|
||||
drawAxis(bounds, timeSystem) {
|
||||
this.setScale(bounds, timeSystem);
|
||||
this.setAxis(bounds);
|
||||
this.axisElement.call(this.xAxis);
|
||||
this.updateNowMarker();
|
||||
|
||||
},
|
||||
setScale(bounds, 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(bounds.start), new Date(bounds.end)]
|
||||
);
|
||||
} else {
|
||||
this.xScale = d3Scale.scaleLinear();
|
||||
this.xScale.domain(
|
||||
[bounds.start, bounds.end]
|
||||
);
|
||||
}
|
||||
|
||||
this.xScale.range([PADDING, this.offsetWidth - PADDING * 2]);
|
||||
},
|
||||
setAxis() {
|
||||
this.xAxis = d3Axis.axisTop(this.xScale);
|
||||
this.xAxis.tickFormat(utcMultiTimeFormat);
|
||||
|
||||
if (this.width > 1800) {
|
||||
this.xAxis.ticks(this.offsetWidth / PIXELS_PER_TICK_WIDE);
|
||||
} else {
|
||||
this.xAxis.ticks(this.offsetWidth / PIXELS_PER_TICK);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
66
src/ui/components/swim-lane/SwimLane.vue
Normal file
66
src/ui/components/swim-lane/SwimLane.vue
Normal file
@ -0,0 +1,66 @@
|
||||
<template>
|
||||
<div class="u-contents"
|
||||
:class="{'c-swim-lane': !isNested}"
|
||||
>
|
||||
|
||||
<div class="c-swim-lane__lane-label c-object-label"
|
||||
:class="{'c-swim-lane__lane-label--span-rows': spanRows, 'c-swim-lane__lane-label--span-cols': (!spanRows && !isNested)}"
|
||||
>
|
||||
<div v-if="iconClass"
|
||||
class="c-object-label__type-icon"
|
||||
:class="iconClass"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="c-object-label__name">
|
||||
<slot name="label"></slot>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<div class="c-swim-lane__lane-object"
|
||||
:style="{'min-height': minHeight}"
|
||||
:class="{'u-contents': showUcontents}"
|
||||
data-selectable
|
||||
>
|
||||
<slot name="object"></slot>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
iconClass: {
|
||||
type: String,
|
||||
default() {
|
||||
return '';
|
||||
}
|
||||
},
|
||||
minHeight: {
|
||||
type: String,
|
||||
default() {
|
||||
return '';
|
||||
}
|
||||
},
|
||||
showUcontents: {
|
||||
type: Boolean,
|
||||
default() {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
isNested: {
|
||||
type: Boolean,
|
||||
default() {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
spanRows: {
|
||||
type: Boolean,
|
||||
default() {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
27
src/ui/components/swim-lane/swim-lane.scss
Normal file
27
src/ui/components/swim-lane/swim-lane.scss
Normal file
@ -0,0 +1,27 @@
|
||||
.c-swim-lane {
|
||||
display: grid;
|
||||
grid-template-columns: 100px 100px 1fr;
|
||||
grid-column-gap: 1px;
|
||||
grid-row-gap: 1px;
|
||||
width: 100%;
|
||||
|
||||
[class*='__lane-label'] {
|
||||
background: rgba($colorBodyFg, 0.2); // TODO: convert to theme constant
|
||||
color: $colorBodyFg; // TODO: convert to theme constant
|
||||
padding: $interiorMarginSm;
|
||||
}
|
||||
|
||||
[class*='--span-cols'] {
|
||||
grid-column: span 2;
|
||||
}
|
||||
|
||||
[class*='--span-rows'] {
|
||||
grid-row: span 4;
|
||||
}
|
||||
|
||||
&__lane-object {
|
||||
.c-plan {
|
||||
display: contents;
|
||||
}
|
||||
}
|
||||
}
|
@ -1,31 +1,15 @@
|
||||
.c-timeline {
|
||||
$h: 18px;
|
||||
$tickYPos: ($h / 2) + 12px + 10px;
|
||||
$tickXPos: 100px;
|
||||
|
||||
height: 100%;
|
||||
.c-timesystem-axis {
|
||||
$h: 30px;
|
||||
height: $h;
|
||||
|
||||
svg {
|
||||
text-rendering: geometricPrecision;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
> g.axis {
|
||||
// Overall Tick holder
|
||||
transform: translateY($tickYPos) translateX($tickXPos);
|
||||
|
||||
g {
|
||||
//Each tick. These move on drag.
|
||||
line {
|
||||
// Line beneath ticks
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
text:not(.activity) {
|
||||
// Tick labels
|
||||
fill: $colorBodyFg;
|
||||
font-size: 1em;
|
||||
paint-order: stroke;
|
||||
font-weight: bold;
|
||||
stroke: $colorBodyBg;
|
||||
@ -33,14 +17,8 @@
|
||||
stroke-linejoin: bevel;
|
||||
stroke-width: 6px;
|
||||
}
|
||||
|
||||
text.activity {
|
||||
stroke: none;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
.nowMarker {
|
||||
width: 2px;
|
||||
position: absolute;
|
Loading…
x
Reference in New Issue
Block a user