fix(#6812): Align Plot and Plan X-Axes in Time Strips (#7744)

* DRAFT - alignment for axes

* Use alignmentContext to manage tick widths instead of passing around as props

* Remove log statements

* Add ability to remove alignment widths for a given y axis

* Fix computation of left margin and width of plan when in the timestrip

* Remove excess padding when there is no left y axis

* Use alignment composable to adjust left margin and width of time system axis

* Fix now marker visibility

* refactor: use built in `Map()` data structure

* refactor: improve readability and conciseness

* docs: improve jsdocs

* refactor: move jsdoc typedefs to bottom of file

* refactor: axis to use vue reactivity

* fix: return alignment as an object of refs

* alignmentMap needs to be shared state, move it out of the useAlignment composable.

* Fix now marker offset

* Add new visual test for time strips

* update with animation stabilization

* Fix failing test due to changed injected property (path -> objectPath)

* change injected property from path to objectPath

* Fix spelling

* Remove unused arguments to function call

---------

Co-authored-by: Jesse Mazzella <jesse.d.mazzella@nasa.gov>
Co-authored-by: Hill, John (ARC-TI)[KBR Wyle Services, LLC] <john.c.hill@nasa.gov>
This commit is contained in:
Shefali Joshi 2024-07-22 16:05:21 -07:00 committed by GitHub
parent 762762945d
commit 1fae0a6ad5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
20 changed files with 461 additions and 284 deletions

View File

@ -0,0 +1,69 @@
/*****************************************************************************
* Open MCT, Copyright (c) 2014-2024, 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 percySnapshot from '@percy/playwright';
import fs from 'fs';
import { createDomainObjectWithDefaults, createPlanFromJSON } from '../../appActions.js';
import { scanForA11yViolations, test } from '../../avpFixtures.js';
import { waitForAnimations } from '../../baseFixtures.js';
import { VISUAL_FIXED_URL } from '../../constants.js';
import { setBoundsToSpanAllActivities } from '../../helper/planningUtils.js';
const examplePlanSmall2 = JSON.parse(
fs.readFileSync(new URL('../../test-data/examplePlans/ExamplePlan_Small2.json', import.meta.url))
);
test.describe('Visual - Time Strip @a11y', () => {
test.beforeEach(async ({ page }) => {
await page.goto(VISUAL_FIXED_URL, { waitUntil: 'domcontentloaded' });
});
test('Time Strip View', async ({ page, theme }) => {
const timeStrip = await createDomainObjectWithDefaults(page, {
type: 'Time Strip',
name: 'Time Strip Visual Test'
});
await createPlanFromJSON(page, {
json: examplePlanSmall2,
parent: timeStrip.uuid
});
await createDomainObjectWithDefaults(page, {
type: 'Sine Wave Generator',
parent: timeStrip.uuid
});
await page.goto(VISUAL_FIXED_URL, { waitUntil: 'domcontentloaded' });
//This will indirectly modify the url such that the SWG is not rendered
await setBoundsToSpanAllActivities(page, examplePlanSmall2, timeStrip.url);
//TODO Find a way to set the "now" activity line
//This will stabilize the state of the test and allow the SWG to render as empty
await waitForAnimations(page.getByLabel('Plot Canvas'));
await percySnapshot(page, `Time Strip View (theme: ${theme}) - With SWG and Plan`);
});
});
test.afterEach(async ({ page }, testInfo) => {
await scanForA11yViolations(page, testInfo.title);
});

View File

@ -74,7 +74,7 @@ export default {
provide() {
return {
domainObject: this.telemetryObject,
path: this.path,
objectPath: this.path,
renderWhenVisible: this.renderWhenVisible
};
},

View File

@ -25,7 +25,7 @@
{{ heading }}
</template>
<template #object>
<svg :height="height" :width="width">
<svg :height="height" :width="svgWidth" :style="alignmentStyle">
<symbol id="activity-bar-bg" :height="rowHeight" width="2" preserveAspectRatio="none">
<rect x="0" y="0" width="100%" height="100%" fill="currentColor" />
<line
@ -92,13 +92,19 @@
</template>
<script>
const AXES_PADDING = 20;
import { inject } from 'vue';
import SwimLane from '@/ui/components/swim-lane/SwimLane.vue';
import { useAlignment } from '../../../ui/composables/alignmentContext.js';
export default {
components: {
SwimLane
},
inject: ['openmct', 'domainObject'],
inject: ['openmct', 'domainObject', 'path'],
props: {
activities: {
type: Array,
@ -136,11 +142,46 @@ export default {
}
},
emits: ['activity-selected'],
setup() {
const domainObject = inject('domainObject');
const path = inject('path');
const openmct = inject('openmct');
const { alignment: alignmentData, reset: resetAlignment } = useAlignment(
domainObject,
path,
openmct
);
return { alignmentData, resetAlignment };
},
data() {
return {
lineHeight: 10
};
},
computed: {
alignmentStyle() {
let leftOffset = 0;
if (this.alignmentData.leftWidth) {
leftOffset = this.alignmentData.multiple ? 2 * AXES_PADDING : AXES_PADDING;
}
return {
marginLeft: `${this.alignmentData.leftWidth + leftOffset}px`
};
},
svgWidth() {
// Reduce the width by left axis width, then take off the right yaxis width as well
let leftOffset = 0;
if (this.alignmentData.leftWidth) {
leftOffset = this.alignmentData.multiple ? 2 * AXES_PADDING : AXES_PADDING;
}
const rightOffset = this.alignmentData.rightWidth ? AXES_PADDING : 0;
return (
this.width -
(this.alignmentData.leftWidth + leftOffset + this.alignmentData.rightWidth + rightOffset)
);
}
},
methods: {
setSelectionForActivity(activity, event) {
event.stopPropagation();

View File

@ -33,14 +33,9 @@
v-for="(yAxis, index) in yAxesIds"
:id="yAxis.id"
:key="`yAxis-${yAxis.id}-${index}`"
:has-multiple-left-axes="hasMultipleLeftAxes"
:position="yAxis.id > 2 ? 'right' : 'left'"
:class="{ 'plot-yaxis-right': yAxis.id > 2 }"
:tick-width="yAxis.tickWidth"
:used-tick-width="plotFirstLeftTickWidth"
:plot-left-tick-width="yAxis.id > 2 ? yAxis.tickWidth : plotLeftTickWidth"
@y-key-changed="setYAxisKey"
@plot-y-tick-width="onYTickWidthChange"
@toggle-axis-visibility="toggleSeriesForYAxis"
/>
</div>
@ -66,7 +61,6 @@
:axis-type="'yAxis'"
:position="'bottom'"
:axis-id="yAxis.id"
@plot-tick-width="onYTickWidthChange"
/>
<div
@ -178,9 +172,10 @@
import Flatbush from 'flatbush';
import _ from 'lodash';
import { useEventBus } from 'utils/useEventBus';
import { toRaw } from 'vue';
import { inject, toRaw } from 'vue';
import { MODES } from '../../api/time/constants';
import { useAlignment } from '../../ui/composables/alignmentContext.js';
import TagEditorClassNames from '../inspectorViews/annotations/tags/TagEditorClassNames.js';
import XAxis from './axis/XAxis.vue';
import YAxis from './axis/YAxis.vue';
@ -201,7 +196,7 @@ export default {
MctTicks,
MctChart
},
inject: ['openmct', 'domainObject', 'path', 'renderWhenVisible'],
inject: ['openmct', 'domainObject', 'objectPath', 'renderWhenVisible'],
props: {
options: {
type: Object,
@ -223,16 +218,6 @@ export default {
return false;
}
},
parentYTickWidth: {
type: Object,
default() {
return {
leftTickWidth: 0,
rightTickWidth: 0,
hasMultipleLeftAxes: false
};
}
},
limitLineLabels: {
type: Object,
default() {
@ -252,15 +237,26 @@ export default {
'grid-lines',
'loading-complete',
'loading-updated',
'plot-y-tick-width',
'highlights',
'lock-highlight-point',
'status-updated'
],
setup() {
const { EventBus } = useEventBus();
const domainObject = inject('domainObject');
const objectPath = inject('objectPath');
const openmct = inject('openmct');
const { alignment: alignmentData, reset: resetAlignment } = useAlignment(
domainObject,
objectPath,
openmct
);
return {
EventBus
EventBus,
alignmentData,
resetAlignment
};
},
data() {
@ -292,15 +288,16 @@ export default {
},
computed: {
xAxisStyle() {
const rightAxis = this.yAxesIds.find((yAxis) => yAxis.id > 2);
const leftOffset = this.hasMultipleLeftAxes ? 2 * AXES_PADDING : AXES_PADDING;
let leftOffset = 0;
if (this.alignmentData.leftWidth) {
leftOffset = this.alignmentData.multiple ? 2 * AXES_PADDING : AXES_PADDING;
}
let style = {
left: `${this.plotLeftTickWidth + leftOffset}px`
left: `${this.alignmentData.leftWidth + leftOffset}px`
};
const parentRightAxisWidth = this.parentYTickWidth.rightTickWidth;
if (parentRightAxisWidth || rightAxis) {
style.right = `${(parentRightAxisWidth || rightAxis.tickWidth) + AXES_PADDING}px`;
if (this.alignmentData.rightWidth) {
style.right = `${this.alignmentData.rightWidth + AXES_PADDING}px`;
}
return style;
@ -308,20 +305,16 @@ export default {
yAxesIds() {
return this.yAxes.filter((yAxis) => yAxis.seriesCount > 0);
},
hasMultipleLeftAxes() {
return (
this.parentYTickWidth.hasMultipleLeftAxes ||
this.yAxes.filter((yAxis) => yAxis.seriesCount > 0 && yAxis.id <= 2).length > 1
);
},
isNestedWithinAStackedPlot() {
const isNavigatedObject = this.openmct.router.isNavigatedObject(
[this.domainObject].concat(this.path)
[this.domainObject].concat(this.objectPath)
);
return (
!isNavigatedObject &&
this.path.find((pathObject, pathObjIndex) => pathObject.type === 'telemetry.plot.stacked')
this.objectPath.find(
(pathObject, pathObjIndex) => pathObject.type === 'telemetry.plot.stacked'
)
);
},
isFrozen() {
@ -331,24 +324,6 @@ export default {
// only allow annotations viewing/editing if plot is paused or in fixed time mode
return this.isFrozen || !this.isRealTime;
},
plotFirstLeftTickWidth() {
const firstYAxis = this.yAxes.find((yAxis) => yAxis.id === 1);
return firstYAxis ? firstYAxis.tickWidth : 0;
},
plotLeftTickWidth() {
let leftTickWidth = 0;
this.yAxes.forEach((yAxis) => {
if (yAxis.id > 2) {
return;
}
leftTickWidth = leftTickWidth + yAxis.tickWidth;
});
const parentLeftTickWidth = this.parentYTickWidth.leftTickWidth;
return parentLeftTickWidth || leftTickWidth;
},
seriesDataLoaded() {
return this.pending === 0 && this.loaded;
}
@ -381,8 +356,7 @@ export default {
this.yAxes = [
{
id: this.config.yAxis.id,
seriesCount: 0,
tickWidth: 0
seriesCount: 0
}
];
if (this.config.additionalYAxes) {
@ -390,8 +364,7 @@ export default {
this.config.additionalYAxes.map((yAxis) => {
return {
id: yAxis.id,
seriesCount: 0,
tickWidth: 0
seriesCount: 0
};
})
);
@ -425,6 +398,7 @@ export default {
});
},
beforeUnmount() {
this.resetAlignment();
this.abortController.abort();
this.openmct.selection.off('change', this.updateSelection);
document.removeEventListener('keydown', this.handleKeyDown);
@ -549,7 +523,7 @@ export default {
},
setTimeContext() {
this.stopFollowingTimeContext();
this.timeContext = this.openmct.time.getContextForView(this.path);
this.timeContext = this.openmct.time.getContextForView(this.objectPath);
this.followTimeContext();
},
followTimeContext() {
@ -605,14 +579,6 @@ export default {
updateTicksAndSeriesForYAxis(newAxisId, oldAxisId) {
this.updateAxisUsageCount(oldAxisId, -1);
this.updateAxisUsageCount(newAxisId, 1);
const foundYAxis = this.yAxes.find((yAxis) => yAxis.id === oldAxisId);
if (foundYAxis.seriesCount === 0) {
this.onYTickWidthChange({
width: foundYAxis.tickWidth,
yAxisId: foundYAxis.id
});
}
},
updateAxisUsageCount(yAxisId, updateCountBy) {
@ -1019,49 +985,6 @@ export default {
}
},
/**
* Aggregate widths of all left and right y axes and send them up to any parent plots
* @param {Object} tickWidthWithYAxisId - the width and yAxisId of the tick bar
* @param fromDifferentObject
*/
onYTickWidthChange(tickWidthWithYAxisId, fromDifferentObject) {
const { width, yAxisId } = tickWidthWithYAxisId;
if (yAxisId) {
const index = this.yAxes.findIndex((yAxis) => yAxis.id === yAxisId);
if (fromDifferentObject) {
// Always accept tick width if it comes from a different object.
this.yAxes[index].tickWidth = width;
} else {
// Otherwise, only accept tick with if it's larger.
const newWidth = Math.max(width, this.yAxes[index].tickWidth);
if (width !== this.yAxes[index].tickWidth) {
this.yAxes[index].tickWidth = newWidth;
}
}
const id = this.openmct.objects.makeKeyString(this.domainObject.identifier);
const leftTickWidth = this.yAxes
.filter((yAxis) => yAxis.id < 3)
.reduce((previous, current) => {
return previous + current.tickWidth;
}, 0);
const rightTickWidth = this.yAxes
.filter((yAxis) => yAxis.id > 2)
.reduce((previous, current) => {
return previous + current.tickWidth;
}, 0);
this.$emit(
'plot-y-tick-width',
{
hasMultipleLeftAxes: this.hasMultipleLeftAxes,
leftTickWidth,
rightTickWidth
},
id
);
}
},
toggleSeriesForYAxis({ id, visible }) {
//if toggling to visible, re-fetch the data for the series that are part of this y Axis
if (visible === true) {
@ -1311,7 +1234,7 @@ export default {
item: this.domainObject
}
});
this.path.forEach((pathObject, index) => {
this.objectPath.forEach((pathObject, index) => {
selection.push({
element: this.openmct.layout.$refs.browseObject.$el,
context: {

View File

@ -73,6 +73,9 @@
</template>
<script>
import { inject } from 'vue';
import { useAlignment } from '../../ui/composables/alignmentContext.js';
import configStore from './configuration/ConfigStore.js';
import eventHelpers from './lib/eventHelpers.js';
import { getFormattedTicks, getLogTicks, ticks } from './tickUtils.js';
@ -80,7 +83,7 @@ import { getFormattedTicks, getLogTicks, ticks } from './tickUtils.js';
const SECONDARY_TICK_NUMBER = 2;
export default {
inject: ['openmct', 'domainObject'],
inject: ['openmct', 'domainObject', 'objectPath'],
props: {
axisType: {
type: String,
@ -111,6 +114,18 @@ export default {
}
},
emits: ['plot-tick-width'],
setup() {
const domainObject = inject('domainObject');
const objectPath = inject('objectPath');
const openmct = inject('openmct');
const { update: updateAlignment, remove: removeAlignment } = useAlignment(
domainObject,
objectPath,
openmct
);
return { updateAlignment, removeAlignment };
},
data() {
return {
ticks: []
@ -132,6 +147,10 @@ export default {
this.updateTicks();
},
beforeUnmount() {
this.removeAlignment({
yAxisId: this.axisId,
updateObjectPath: this.objectPath
});
this.stopListening();
},
methods: {
@ -279,6 +298,14 @@ export default {
width: tickWidth,
yAxisId: this.axisType === 'yAxis' ? this.axisId : ''
});
if (this.axisType === 'yAxis') {
this.updateAlignment({
width: tickWidth,
yAxisId: this.axisId,
updateObjectPath: this.objectPath
});
}
this.shouldCheckWidth = false;
}
}

View File

@ -45,14 +45,12 @@
:init-cursor-guide="cursorGuide"
:options="options"
:limit-line-labels="limitLineLabelsProp"
:parent-y-tick-width="parentYTickWidth"
:color-palette="colorPalette"
@loading-updated="loadingUpdated"
@status-updated="setStatus"
@config-loaded="updateReady"
@lock-highlight-point="lockHighlightPointUpdated"
@highlights="highlightsUpdated"
@plot-y-tick-width="onYTickWidthChange"
@cursor-guide="onCursorGuideChange"
@grid-lines="onGridLinesChange"
>
@ -85,7 +83,7 @@ export default {
PlotLegend
},
mixins: [stalenessMixin],
inject: ['openmct', 'domainObject'],
inject: ['openmct', 'domainObject', 'objectPath'],
props: {
options: {
type: Object,
@ -119,16 +117,6 @@ export default {
return undefined;
}
},
parentYTickWidth: {
type: Object,
default() {
return {
leftTickWidth: 0,
rightTickWidth: 0,
hasMultipleLeftAxes: false
};
}
},
hideLegend: {
type: Boolean,
default() {
@ -142,7 +130,6 @@ export default {
'grid-lines',
'highlights',
'config-loaded',
'plot-y-tick-width',
'cursor-guide'
],
data() {
@ -261,9 +248,6 @@ export default {
this.configReady = ready;
this.$emit('config-loaded', ...arguments);
},
onYTickWidthChange() {
this.$emit('plot-y-tick-width', ...arguments);
},
onCursorGuideChange() {
this.$emit('cursor-guide', ...arguments);
},

View File

@ -76,7 +76,7 @@ export default function PlotViewProvider(openmct) {
provide: {
openmct,
domainObject,
path: objectPath,
objectPath,
renderWhenVisible
},
data() {

View File

@ -71,6 +71,9 @@
</template>
<script>
import { inject } from 'vue';
import { useAlignment } from '../../../ui/composables/alignmentContext.js';
import configStore from '../configuration/ConfigStore.js';
import eventHelpers from '../lib/eventHelpers.js';
import MctTicks from '../MctTicks.vue';
@ -81,7 +84,7 @@ export default {
components: {
MctTicks
},
inject: ['openmct', 'domainObject'],
inject: ['openmct', 'domainObject', 'objectPath'],
props: {
id: {
type: Number,
@ -89,30 +92,6 @@ export default {
return 1;
}
},
tickWidth: {
type: Number,
default() {
return 0;
}
},
plotLeftTickWidth: {
type: Number,
default() {
return 0;
}
},
usedTickWidth: {
type: Number,
default() {
return 0;
}
},
hasMultipleLeftAxes: {
type: Boolean,
default() {
return false;
}
},
position: {
type: String,
default() {
@ -120,7 +99,15 @@ export default {
}
}
},
emits: ['plot-y-tick-width', 'toggle-axis-visibility', 'y-key-changed'],
emits: ['toggle-axis-visibility', 'y-key-changed'],
setup() {
const domainObject = inject('domainObject');
const objectPath = inject('objectPath');
const openmct = inject('openmct');
const { alignment: alignmentData } = useAlignment(domainObject, objectPath, openmct);
return { alignmentData };
},
data() {
return {
yAxisLabel: 'none',
@ -131,7 +118,8 @@ export default {
mainYAxisId: null,
hasAdditionalYAxes: false,
seriesColors: [],
visible: true
visible: true,
selfTickWidth: 0
};
},
computed: {
@ -143,19 +131,20 @@ export default {
},
yAxisStyle() {
let style = {
width: `${this.tickWidth + AXIS_PADDING}px`
width: `${this.selfTickWidth + AXIS_PADDING}px`
};
const multipleAxesPadding = this.hasMultipleLeftAxes ? AXIS_PADDING : 0;
const multipleAxesPadding = this.alignmentData.multiple ? AXIS_PADDING : 0;
if (this.position === 'right') {
style.left = `-${this.tickWidth + AXIS_PADDING}px`;
style.left = `-${this.selfTickWidth + AXIS_PADDING}px`;
} else {
const thisIsTheSecondLeftAxis = this.id - 1 > 0;
if (this.hasMultipleLeftAxes && thisIsTheSecondLeftAxis) {
style.left = `${this.plotLeftTickWidth - this.usedTickWidth - this.tickWidth}px`;
if (this.alignmentData.multiple && thisIsTheSecondLeftAxis) {
const otherAxisWidth = this.alignmentData.leftWidth - this.selfTickWidth;
style.left = `${this.alignmentData.leftWidth - otherAxisWidth - this.selfTickWidth}px`;
style['border-right'] = `1px solid`;
} else {
style.left = `${this.plotLeftTickWidth - this.tickWidth + multipleAxesPadding}px`;
style.left = `${this.alignmentData.leftWidth - this.selfTickWidth + multipleAxesPadding}px`;
}
}
@ -265,10 +254,7 @@ export default {
}
},
onTickWidthChange(data) {
this.$emit('plot-y-tick-width', {
width: data.width,
yAxisId: this.id
});
this.selfTickWidth = data.width;
},
toggleSeriesVisibility() {
this.visible = !this.visible;

View File

@ -111,7 +111,7 @@ const HANDLED_ATTRIBUTES = {
export default {
components: { LimitLine, LimitLabel },
inject: ['openmct', 'domainObject', 'path', 'renderWhenVisible'],
inject: ['openmct', 'domainObject', 'objectPath', 'renderWhenVisible'],
props: {
rectangles: {
type: Array,

View File

@ -58,7 +58,7 @@ export default function OverlayPlotViewProvider(openmct) {
provide: {
openmct,
domainObject,
path: objectPath,
objectPath,
renderWhenVisible
},
data() {

View File

@ -315,7 +315,7 @@ describe('the plugin', function () {
openmct,
domainObject: overlayPlotObject,
composition,
path: [overlayPlotObject],
objectPath: [overlayPlotObject],
renderWhenVisible
},
template: '<plot ref="plotComponent"></plot>'
@ -507,7 +507,7 @@ describe('the plugin', function () {
openmct: openmct,
domainObject: overlayPlotObject,
composition,
path: [overlayPlotObject],
objectPath: [overlayPlotObject],
renderWhenVisible
},
template: '<plot ref="plotComponent"></plot>'

View File

@ -48,9 +48,7 @@
:color-palette="colorPalette"
:cursor-guide="cursorGuide"
:show-limit-line-labels="showLimitLineLabels"
:parent-y-tick-width="maxTickWidth"
:hide-legend="showLegendsForChildren === false"
@plot-y-tick-width="onYTickWidthChange"
@loading-updated="loadingUpdated"
@cursor-guide="onCursorGuideChange"
@grid-lines="onGridLinesChange"
@ -63,9 +61,12 @@
</template>
<script>
import { inject } from 'vue';
import ColorPalette from '@/ui/color/ColorPalette';
import ImageExporter from '../../../exporters/ImageExporter.js';
import { useAlignment } from '../../../ui/composables/alignmentContext.js';
import configStore from '../configuration/ConfigStore.js';
import PlotConfigurationModel from '../configuration/PlotConfigurationModel.js';
import PlotLegend from '../legend/PlotLegend.vue';
@ -77,7 +78,7 @@ export default {
StackedPlotItem,
PlotLegend
},
inject: ['openmct', 'domainObject', 'path', 'renderWhenVisible'],
inject: ['openmct', 'domainObject', 'objectPath', 'renderWhenVisible'],
props: {
options: {
type: Object,
@ -86,6 +87,18 @@ export default {
}
}
},
setup() {
const domainObject = inject('domainObject');
const objectPath = inject('objectPath');
const openmct = inject('openmct');
const { alignment: alignmentData, reset: resetAlignment } = useAlignment(
domainObject,
objectPath,
openmct
);
return { alignmentData, resetAlignment };
},
data() {
return {
hideExportButtons: false,
@ -93,7 +106,6 @@ export default {
gridLines: true,
configLoaded: {},
compositionObjects: [],
tickWidthMap: {},
loaded: false,
lockHighlightPoint: false,
highlights: [],
@ -123,28 +135,6 @@ export default {
}
return legendExpandedStateClass;
},
/**
* Returns the maximum width of the left and right y axes ticks of this stacked plots children
* @returns {{rightTickWidth: number, leftTickWidth: number, hasMultipleLeftAxes: boolean}}
*/
maxTickWidth() {
const tickWidthValues = Object.values(this.tickWidthMap);
const maxLeftTickWidth = Math.max(
...tickWidthValues.map((tickWidthItem) => tickWidthItem.leftTickWidth)
);
const maxRightTickWidth = Math.max(
...tickWidthValues.map((tickWidthItem) => tickWidthItem.rightTickWidth)
);
const hasMultipleLeftAxes = tickWidthValues.some(
(tickWidthItem) => tickWidthItem.hasMultipleLeftAxes === true
);
return {
leftTickWidth: maxLeftTickWidth,
rightTickWidth: maxRightTickWidth,
hasMultipleLeftAxes
};
}
},
beforeUnmount() {
@ -209,6 +199,7 @@ export default {
}
},
destroy() {
this.resetAlignment();
this.composition.off('add', this.addChild);
this.composition.off('remove', this.removeChild);
this.composition.off('reorder', this.compositionReorder);
@ -226,11 +217,6 @@ export default {
const id = this.openmct.objects.makeKeyString(child.identifier);
this.tickWidthMap[id] = {
leftTickWidth: 0,
rightTickWidth: 0
};
this.compositionObjects.push({
object: child,
keyString: id
@ -241,8 +227,6 @@ export default {
removeChild(childIdentifier) {
const id = this.openmct.objects.makeKeyString(childIdentifier);
delete this.tickWidthMap[id];
const childObj = this.compositionObjects.filter((c) => {
const identifier = c.keyString;
@ -283,12 +267,8 @@ export default {
});
},
resetTelemetryAndTicks(domainObject) {
resetTelemetry(domainObject) {
this.compositionObjects = [];
this.tickWidthMap = {
leftTickWidth: 0,
rightTickWidth: 0
};
},
exportJPG() {
@ -313,19 +293,6 @@ export default {
}.bind(this)
);
},
/**
* @typedef {Object} PlotYTickData
* @property {number} leftTickWidth the width of the ticks for all the y axes on the left of the plot.
* @property {number} rightTickWidth the width of the ticks for all the y axes on the right of the plot.
* @property {boolean} hasMultipleLeftAxes whether or not there is more than one left y axis.
*/
onYTickWidthChange(data, plotId) {
if (!Object.prototype.hasOwnProperty.call(this.tickWidthMap, plotId)) {
return;
}
this.tickWidthMap[plotId] = data;
},
legendHoverChanged(data) {
this.showLimitLineLabels = data;
},

View File

@ -27,14 +27,12 @@
:limit-line-labels="showLimitLineLabels"
:grid-lines="gridLines"
:cursor-guide="cursorGuide"
:parent-y-tick-width="parentYTickWidth"
:options="options"
:color-palette="colorPalette"
:class="isStale && 'is-stale'"
@config-loaded="onConfigLoaded"
@lock-highlight-point="onLockHighlightPointUpdated"
@highlights="onHighlightsUpdated"
@plot-y-tick-width="onYTickWidthChange"
@cursor-guide="onCursorGuideChange"
@grid-lines="onGridLinesChange"
/>
@ -53,7 +51,7 @@ export default {
Plot
},
mixins: [conditionalStylesMixin, stalenessMixin],
inject: ['openmct', 'domainObject', 'path', 'renderWhenVisible'],
inject: ['openmct', 'domainObject', 'objectPath', 'renderWhenVisible'],
provide() {
return {
openmct: this.openmct,
@ -97,16 +95,6 @@ export default {
return undefined;
}
},
parentYTickWidth: {
type: Object,
default() {
return {
leftTickWidth: 0,
rightTickWidth: 0,
hasMultipleLeftAxes: false
};
}
},
hideLegend: {
type: Boolean,
default() {
@ -201,9 +189,6 @@ export default {
onConfigLoaded() {
this.$emit('config-loaded', ...arguments);
},
onYTickWidthChange() {
this.$emit('plot-y-tick-width', ...arguments);
},
onCursorGuideChange() {
this.$emit('cursor-guide', ...arguments);
},

View File

@ -60,7 +60,7 @@ export default function StackedPlotViewProvider(openmct) {
provide: {
openmct,
domainObject,
path: objectPath,
objectPath,
renderWhenVisible
},
data() {

View File

@ -24,7 +24,7 @@ import StyleRuleManager from '@/plugins/condition/StyleRuleManager';
import { STYLE_CONSTANTS } from '@/plugins/condition/utils/constants';
export default {
inject: ['openmct', 'domainObject', 'path'],
inject: ['openmct', 'domainObject', 'objectPath'],
data() {
return {
objectStyle: undefined

View File

@ -330,7 +330,7 @@ describe('the plugin', function () {
provide: {
openmct,
domainObject: stackedPlotObject,
path: [stackedPlotObject],
objectPath: [stackedPlotObject],
renderWhenVisible
},
template: '<stacked-plot ref="stackedPlotRef"></stacked-plot>'

View File

@ -49,10 +49,12 @@
<script>
import _ from 'lodash';
import { inject } from 'vue';
import SwimLane from '@/ui/components/swim-lane/SwimLane.vue';
import TimelineAxis from '../../ui/components/TimeSystemAxis.vue';
import { useAlignment } from '../../ui/composables/alignmentContext.js';
import { getValidatedData, getValidatedGroups } from '../plan/util.js';
import TimelineObjectView from './TimelineObjectView.vue';
@ -69,7 +71,19 @@ export default {
TimelineAxis,
SwimLane
},
inject: ['openmct', 'domainObject', 'composition', 'objectPath'],
inject: ['openmct', 'domainObject', 'path', 'composition'],
setup() {
const domainObject = inject('domainObject');
const path = inject('path');
const openmct = inject('openmct');
const { alignment: alignmentData, reset: resetAlignment } = useAlignment(
domainObject,
path,
openmct
);
return { alignmentData, resetAlignment };
},
data() {
return {
items: [],
@ -80,6 +94,7 @@ export default {
};
},
beforeUnmount() {
this.resetAlignment();
this.composition.off('add', this.addItem);
this.composition.off('remove', this.removeItem);
this.composition.off('reorder', this.reorder);
@ -105,7 +120,7 @@ export default {
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 objectPath = [domainObject].concat(this.path.slice());
let rowCount = 0;
if (domainObject.type === 'plan') {
const planData = getValidatedData(domainObject);
@ -195,7 +210,7 @@ export default {
setTimeContext() {
this.stopFollowingTimeContext();
this.timeContext = this.openmct.time.getContextForView(this.objectPath);
this.timeContext = this.openmct.time.getContextForView(this.path);
this.getTimeSystems();
this.updateViewBounds();
this.timeContext.on('boundsChanged', this.updateViewBounds);

View File

@ -51,8 +51,8 @@ export default function TimelineViewProvider(openmct) {
provide: {
openmct,
domainObject,
composition: openmct.composition.get(domainObject),
objectPath
path: objectPath,
composition: openmct.composition.get(domainObject)
},
template: '<timeline-view-layout></timeline-view-layout>'
},

View File

@ -21,28 +21,32 @@
-->
<template>
<div ref="axisHolder" class="c-timesystem-axis">
<div class="nowMarker"><span class="icon-arrow-down"></span></div>
<div class="nowMarker" :style="nowMarkerStyle"><span class="icon-arrow-down"></span></div>
<svg :width="svgWidth" :height="svgHeight">
<g class="axis" font-size="1.3em" :transform="axisTransform"></g>
</svg>
</div>
</template>
<script>
const AXES_PADDING = 20;
import { axisTop } from 'd3-axis';
import { scaleLinear, scaleUtc } from 'd3-scale';
import { select } from 'd3-selection';
import { onMounted, ref } from 'vue';
import { inject, onMounted, reactive, ref } from 'vue';
import utcMultiTimeFormat from '@/plugins/timeConductor/utcMultiTimeFormat';
import { useAlignment } from '../composables/alignmentContext';
import { useResizeObserver } from '../composables/resize';
//TODO: UI direction needed for the following property values
const PADDING = 1;
const PIXELS_PER_TICK = 100;
const PIXELS_PER_TICK_WIDE = 200;
//This offset needs to be re-considered
export default {
inject: ['openmct', 'domainObject'],
inject: ['openmct', 'domainObject', 'path'],
props: {
bounds: {
type: Object,
@ -67,31 +71,64 @@ export default {
default() {
return 'svg';
}
},
offset: {
type: Number,
default() {
return 0;
}
}
},
setup() {
const axisHolder = ref(null);
const { size: containerSize, startObserving } = useResizeObserver();
const svgWidth = ref(0);
const svgHeight = ref(0);
const axisTransform = ref('translate(0,20)');
const nowMarkerStyle = reactive({
height: '0px',
left: '0px'
});
onMounted(() => {
startObserving(axisHolder.value);
});
const domainObject = inject('domainObject');
const objectPath = inject('path');
const openmct = inject('openmct');
const { alignment: alignmentData } = useAlignment(domainObject, objectPath, openmct);
return {
axisHolder,
containerSize
containerSize,
alignmentData,
svgWidth,
svgHeight,
axisTransform,
nowMarkerStyle,
openmct
};
},
watch: {
alignmentData: {
handler() {
let leftOffset = 0;
if (this.alignmentData.leftWidth) {
leftOffset = this.alignmentData.multiple ? 2 * AXES_PADDING : AXES_PADDING;
}
this.axisTransform = `translate(${this.alignmentData.leftWidth + leftOffset}, 20)`;
const rightOffset = this.alignmentData.rightWidth ? AXES_PADDING : 0;
this.alignmentOffset =
this.alignmentData.leftWidth + leftOffset + this.alignmentData.rightWidth + rightOffset;
this.refresh();
},
deep: true
},
bounds(newBounds) {
this.setDimensions();
this.drawAxis(newBounds, this.timeSystem);
this.updateNowMarker();
},
timeSystem(newTimeSystem) {
this.setDimensions();
this.drawAxis(this.bounds, newTimeSystem);
this.updateNowMarker();
},
contentHeight() {
this.updateNowMarker();
@ -109,16 +146,10 @@ export default {
}
this.container = select(this.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.svgElement = this.container.select('svg');
this.axisElement = this.svgElement.select('g.axis');
this.setDimensions();
this.drawAxis(this.bounds, this.timeSystem);
this.refresh();
this.resize();
},
unmounted() {
@ -126,33 +157,37 @@ export default {
},
methods: {
resize() {
if (this.axisHolder.clientWidth !== this.width) {
this.setDimensions();
this.drawAxis(this.bounds, this.timeSystem);
this.updateNowMarker();
if (this.axisHolder.clientWidth - this.alignmentOffset !== this.width) {
this.refresh();
}
},
refresh() {
this.setDimensions();
this.drawAxis(this.bounds, this.timeSystem);
this.updateNowMarker();
},
updateNowMarker() {
let nowMarker = this.$el.querySelector('.nowMarker');
const nowMarker = this.$el.querySelector('.nowMarker');
if (nowMarker) {
nowMarker.classList.remove('hidden');
nowMarker.style.height = this.contentHeight + 'px';
this.nowMarkerStyle.height = this.contentHeight + 'px';
const nowTimeStamp = this.openmct.time.now();
const now = this.xScale(nowTimeStamp);
nowMarker.style.left = now + this.offset + 'px';
this.nowMarkerStyle.left = `${now + this.alignmentOffset}px`;
if (now > this.width) {
nowMarker.classList.add('hidden');
}
}
},
setDimensions() {
this.width = this.axisHolder.clientWidth;
this.offsetWidth = this.width - this.offset;
this.width = this.axisHolder.clientWidth - (this.alignmentOffset ?? 0);
this.height = Math.round(this.axisHolder.getBoundingClientRect().height);
if (this.useSVG) {
this.svgElement.attr('width', this.width);
this.svgElement.attr('height', this.height);
this.svgWidth = this.width;
this.svgHeight = this.height;
} else {
this.svgElement.attr('height', 50);
this.svgHeight = 50;
}
},
drawAxis(bounds, timeSystem) {
@ -180,16 +215,16 @@ export default {
this.xScale.domain([bounds.start, bounds.end]);
}
this.xScale.range([PADDING, this.offsetWidth - PADDING * 2]);
this.xScale.range([PADDING, this.width - PADDING * 2]);
},
setAxis() {
this.xAxis = axisTop(this.xScale);
this.xAxis.tickFormat(utcMultiTimeFormat);
if (this.width > 1800) {
this.xAxis.ticks(this.offsetWidth / PIXELS_PER_TICK_WIDE);
this.xAxis.ticks(this.width / PIXELS_PER_TICK_WIDE);
} else {
this.xAxis.ticks(this.offsetWidth / PIXELS_PER_TICK);
this.xAxis.ticks(this.width / PIXELS_PER_TICK);
}
}
}

View File

@ -0,0 +1,145 @@
/*****************************************************************************
* Open MCT, Copyright (c) 2014-2024, 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.
*****************************************************************************/
/* eslint-disable func-style */
import { reactive } from 'vue';
/** @type {Map<string, Alignment>} */
const alignmentMap = new Map();
/**
* Manages alignment for multiple y axes given an object path.
* This is a Vue composition API utility function.
* @param {Object} targetObject - The target to attach the event listener to.
* @param {ObjectPath} objectPath - The path of the target object.
* @param {import('../../../openmct.js').OpenMCT} openmct - The open mct API.
* @returns {Object} An object containing alignment data and methods to update, remove, and reset alignment.
*/
export function useAlignment(targetObject, objectPath, openmct) {
/**
* Get the alignment key for the given path.
* @returns {string|undefined} The alignment key if found, otherwise undefined.
*/
const getAlignmentKeyForPath = () => {
const keys = Array.from(alignmentMap.keys());
return objectPath
.map((domainObject) => openmct.objects.makeKeyString(domainObject.identifier))
.reverse()
.find((keyString) => keys.includes(keyString));
};
// Use the furthest ancestor's alignment if it exists, otherwise, use your own
let alignmentKey =
getAlignmentKeyForPath() || openmct.objects.makeKeyString(targetObject.identifier);
if (!alignmentMap.has(alignmentKey)) {
alignmentMap.set(
alignmentKey,
reactive({
leftWidth: 0,
rightWidth: 0,
multiple: false,
axes: {}
})
);
}
/**
* Reset any alignment data for the given key.
*/
const reset = () => {
const key = getAlignmentKeyForPath();
if (key && alignmentMap.has(key)) {
alignmentMap.delete(key);
}
};
/**
* Given the axes ids and widths, calculate the max left and right widths and whether or not multiple left axes exist.
*/
const processAlignment = () => {
const alignment = alignmentMap.get(alignmentKey);
const axesKeys = Object.keys(alignment.axes);
const leftAxes = axesKeys.filter((axis) => axis <= 2);
const rightAxes = axesKeys.filter((axis) => axis > 2);
alignment.leftWidth = leftAxes.reduce((sum, axis) => sum + (alignment.axes[axis] || 0), 0);
alignment.rightWidth = rightAxes.reduce((sum, axis) => sum + (alignment.axes[axis] || 0), 0);
alignment.multiple = leftAxes.length > 1;
};
/**
* @typedef {Object} RemoveParams
* @property {number} yAxisId - The ID of the y-axis to remove.
* @property {ObjectPath} [updateObjectPath] - The path of the object to update.
*/
/**
* Unregister y-axis from width calculations.
* @param {RemoveParams} param0 - The object containing yAxisId and updateObjectPath.
*/
const remove = ({ yAxisId, updateObjectPath } = {}) => {
const key = getAlignmentKeyForPath();
if (key) {
const alignment = alignmentMap.get(alignmentKey);
if (alignment.axes[yAxisId] !== undefined) {
delete alignment.axes[yAxisId];
}
processAlignment();
}
};
/**
* @typedef {Object} UpdateParams
* @property {number} width - The width of the y-axis.
* @property {number} yAxisId - The ID of the y-axis to update.
* @property {ObjectPath} [updateObjectPath] - The path of the object to update.
*/
/**
* Update widths of a y axis given the id and path. The path is used to determine which ancestor should hold the alignment.
* @param {UpdateParams} param0 - The object containing width, yAxisId, and updateObjectPath.
*/
const update = ({ width, yAxisId, updateObjectPath } = {}) => {
const key = getAlignmentKeyForPath();
if (key) {
const alignment = alignmentMap.get(alignmentKey);
if (alignment.axes[yAxisId] === undefined || width > alignment.axes[yAxisId]) {
alignment.axes[yAxisId] = width;
}
processAlignment();
}
};
return { alignment: alignmentMap.get(alignmentKey), update, remove, reset };
}
/**
* @typedef {import('../../api/objects/ObjectAPI.js').DomainObject[]} ObjectPath
*/
/**
* @typedef {Object} Alignment
* @property {number} leftWidth - The total width of the left axes.
* @property {number} rightWidth - The total width of the right axes.
* @property {boolean} multiple - Indicates if there are multiple left axes.
* @property {Object.<string, number>} axes - A map of axis IDs to their widths.
*/