Files
openmct/src/plugins/plot/chart/MctChart.vue
Charles Hacskaylo 2889e88a97 Styling for plot limits (#3917)
* Styling for plot limits and colors
* Updates to limit provider and css
* Change limits related CSS "*--upper" and "*--lower" to "*--upr" and
"*--lwr" for better parity with legacy naming;
* Refactor limit class to util
* Use new classes for sine wave generator for red-low and yellow-high
* Added modifier classes for right and below-aligned labels;
* Prevent label overlap of limits as much as possible
* Add border colors to limit labels for better visual ties to their lines
* Add documentation for limit level specification API change

Co-authored-by: Shefali Joshi <simplyrender@gmail.com>
Co-authored-by: Andrew Henry <akhenry@gmail.com>
2021-06-21 16:22:28 -07:00

621 lines
21 KiB
Vue

<!--
Open MCT, Copyright (c) 2014-2021, United States Government
as represented by the Administrator of the National Aeronautics and Space
Administration. All rights reserved.
Open MCT is licensed under the Apache License, Version 2.0 (the
"License"); you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0.
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
License for the specific language governing permissions and limitations
under the License.
Open MCT includes source code licensed under additional open source
licenses. See the Open Source Licenses file (LICENSES.md) included with
this source code distribution or the Licensing information page available
at runtime from the About dialog for additional information.
-->
<template>
<div class="gl-plot-chart-area">
<span v-html="canvasTemplate"></span>
<span v-html="canvasTemplate"></span>
<div ref="limitArea"
class="js-limit-area"
></div>
</div>
</template>
<script>
import eventHelpers from "../lib/eventHelpers";
import { DrawLoader } from '../draw/DrawLoader';
import MCTChartLineLinear from './MCTChartLineLinear';
import MCTChartLineStepAfter from './MCTChartLineStepAfter';
import MCTChartPointSet from './MCTChartPointSet';
import MCTChartAlarmPointSet from './MCTChartAlarmPointSet';
import MCTChartAlarmLineSet from "./MCTChartAlarmLineSet";
import configStore from "../configuration/configStore";
import PlotConfigurationModel from "../configuration/PlotConfigurationModel";
import LimitLine from "./LimitLine.vue";
import LimitLabel from "./LimitLabel.vue";
import Vue from 'vue';
const MARKER_SIZE = 6.0;
const HIGHLIGHT_SIZE = MARKER_SIZE * 2.0;
const CLEARANCE = 15;
export default {
inject: ['openmct', 'domainObject'],
props: {
rectangles: {
type: Array,
default() {
return [];
}
},
highlights: {
type: Array,
default() {
return [];
}
},
showLimitLineLabels: {
type: Object,
default() {
return {};
}
}
},
data() {
return {
canvasTemplate: '<canvas style="position: absolute; background: none; width: 100%; height: 100%;"></canvas>'
};
},
watch: {
highlights() {
this.scheduleDraw();
},
rectangles() {
this.scheduleDraw();
},
showLimitLineLabels() {
this.drawLimitLines();
}
},
mounted() {
eventHelpers.extend(this);
this.config = this.getConfig();
this.isDestroyed = false;
this.lines = [];
this.limitLines = [];
this.pointSets = [];
this.alarmSets = [];
this.offset = {};
this.seriesElements = new WeakMap();
this.seriesLimits = new WeakMap();
let canvasEls = this.$parent.$refs.chartContainer.querySelectorAll("canvas");
const mainCanvas = canvasEls[1];
const overlayCanvas = canvasEls[0];
if (this.initializeCanvas(mainCanvas, overlayCanvas)) {
this.draw();
}
this.listenTo(this.config.series, 'add', this.onSeriesAdd, this);
this.listenTo(this.config.series, 'remove', this.onSeriesRemove, this);
this.listenTo(this.config.yAxis, 'change:key', this.clearOffset, this);
this.listenTo(this.config.yAxis, 'change', this.updateLimitsAndDraw);
this.listenTo(this.config.xAxis, 'change', this.updateLimitsAndDraw);
this.config.series.forEach(this.onSeriesAdd, this);
},
beforeDestroy() {
this.destroy();
},
methods: {
getConfig() {
const configId = this.openmct.objects.makeKeyString(this.domainObject.identifier);
let config = configStore.get(configId);
if (!config) {
config = new PlotConfigurationModel({
id: configId,
domainObject: this.domainObject,
openmct: this.openmct
});
configStore.add(configId, config);
}
return config;
},
reDraw(mode, o, series) {
this.changeInterpolate(mode, o, series);
this.changeMarkers(mode, o, series);
this.changeAlarmMarkers(mode, o, series);
this.changeLimitLines(mode, o, series);
},
onSeriesAdd(series) {
this.listenTo(series, 'change:xKey', this.reDraw, this);
this.listenTo(series, 'change:interpolate', this.changeInterpolate, this);
this.listenTo(series, 'change:markers', this.changeMarkers, this);
this.listenTo(series, 'change:alarmMarkers', this.changeAlarmMarkers, this);
this.listenTo(series, 'change:limitLines', this.changeLimitLines, this);
this.listenTo(series, 'change', this.scheduleDraw);
this.listenTo(series, 'add', this.scheduleDraw);
this.makeChartElement(series);
this.makeLimitLines(series);
},
changeInterpolate(mode, o, series) {
if (mode === o) {
return;
}
const elements = this.seriesElements.get(series);
elements.lines.forEach(function (line) {
this.lines.splice(this.lines.indexOf(line), 1);
line.destroy();
}, this);
elements.lines = [];
const newLine = this.lineForSeries(series);
if (newLine) {
elements.lines.push(newLine);
this.lines.push(newLine);
}
},
changeAlarmMarkers(mode, o, series) {
if (mode === o) {
return;
}
const elements = this.seriesElements.get(series);
if (elements.alarmSet) {
elements.alarmSet.destroy();
this.alarmSets.splice(this.alarmSets.indexOf(elements.alarmSet), 1);
}
elements.alarmSet = this.alarmPointSetForSeries(series);
if (elements.alarmSet) {
this.alarmSets.push(elements.alarmSet);
}
},
changeMarkers(mode, o, series) {
if (mode === o) {
return;
}
const elements = this.seriesElements.get(series);
elements.pointSets.forEach(function (pointSet) {
this.pointSets.splice(this.pointSets.indexOf(pointSet), 1);
pointSet.destroy();
}, this);
elements.pointSets = [];
const pointSet = this.pointSetForSeries(series);
if (pointSet) {
elements.pointSets.push(pointSet);
this.pointSets.push(pointSet);
}
},
changeLimitLines(mode, o, series) {
if (mode === o) {
return;
}
this.makeLimitLines(series);
this.updateLimitsAndDraw();
},
onSeriesRemove(series) {
this.stopListening(series);
this.removeChartElement(series);
this.scheduleDraw();
},
destroy() {
this.isDestroyed = true;
this.stopListening();
this.lines.forEach(line => line.destroy());
this.limitLines.forEach(line => line.destroy());
DrawLoader.releaseDrawAPI(this.drawAPI);
},
clearOffset() {
delete this.offset.x;
delete this.offset.y;
delete this.offset.xVal;
delete this.offset.yVal;
delete this.offset.xKey;
delete this.offset.yKey;
this.lines.forEach(function (line) {
line.reset();
});
this.limitLines.forEach(function (line) {
line.reset();
});
this.pointSets.forEach(function (pointSet) {
pointSet.reset();
});
},
setOffset(offsetPoint, index, series) {
if (this.offset.x && this.offset.y) {
return;
}
const offsets = {
x: series.getXVal(offsetPoint),
y: series.getYVal(offsetPoint)
};
this.offset.x = function (x) {
return x - offsets.x;
}.bind(this);
this.offset.y = function (y) {
return y - offsets.y;
}.bind(this);
this.offset.xVal = function (point, pSeries) {
return this.offset.x(pSeries.getXVal(point));
}.bind(this);
this.offset.yVal = function (point, pSeries) {
return this.offset.y(pSeries.getYVal(point));
}.bind(this);
},
initializeCanvas(canvas, overlay) {
this.canvas = canvas;
this.overlay = overlay;
this.drawAPI = DrawLoader.getDrawAPI(canvas, overlay);
if (this.drawAPI) {
this.listenTo(this.drawAPI, 'error', this.fallbackToCanvas, this);
}
return Boolean(this.drawAPI);
},
fallbackToCanvas() {
this.stopListening(this.drawAPI);
DrawLoader.releaseDrawAPI(this.drawAPI);
// Have to throw away the old canvas elements and replace with new
// canvas elements in order to get new drawing contexts.
const div = document.createElement('div');
div.innerHTML = this.TEMPLATE;
const mainCanvas = div.querySelectorAll("canvas")[1];
const overlayCanvas = div.querySelectorAll("canvas")[0];
this.canvas.parentNode.replaceChild(mainCanvas, this.canvas);
this.canvas = mainCanvas;
this.overlay.parentNode.replaceChild(overlayCanvas, this.overlay);
this.overlay = overlayCanvas;
this.drawAPI = DrawLoader.getFallbackDrawAPI(this.canvas, this.overlay);
this.$emit('plotReinitializeCanvas');
},
removeChartElement(series) {
const elements = this.seriesElements.get(series);
elements.lines.forEach(function (line) {
this.lines.splice(this.lines.indexOf(line), 1);
line.destroy();
}, this);
elements.pointSets.forEach(function (pointSet) {
this.pointSets.splice(this.pointSets.indexOf(pointSet), 1);
pointSet.destroy();
}, this);
if (elements.alarmSet) {
elements.alarmSet.destroy();
this.alarmSets.splice(this.alarmSets.indexOf(elements.alarmSet), 1);
}
this.seriesElements.delete(series);
this.clearLimitLines(series);
},
lineForSeries(series) {
if (series.get('interpolate') === 'linear') {
return new MCTChartLineLinear(
series,
this,
this.offset
);
}
if (series.get('interpolate') === 'stepAfter') {
return new MCTChartLineStepAfter(
series,
this,
this.offset
);
}
},
limitLineForSeries(series) {
return new MCTChartAlarmLineSet(
series,
this,
this.offset,
this.openmct.time.bounds()
);
},
pointSetForSeries(series) {
if (series.get('markers')) {
return new MCTChartPointSet(
series,
this,
this.offset
);
}
},
alarmPointSetForSeries(series) {
if (series.get('alarmMarkers')) {
return new MCTChartAlarmPointSet(
series,
this,
this.offset
);
}
},
makeChartElement(series) {
const elements = {
lines: [],
pointSets: [],
limitLines: []
};
const line = this.lineForSeries(series);
if (line) {
elements.lines.push(line);
this.lines.push(line);
}
const pointSet = this.pointSetForSeries(series);
if (pointSet) {
elements.pointSets.push(pointSet);
this.pointSets.push(pointSet);
}
elements.alarmSet = this.alarmPointSetForSeries(series);
if (elements.alarmSet) {
this.alarmSets.push(elements.alarmSet);
}
this.seriesElements.set(series, elements);
},
makeLimitLines(series) {
this.clearLimitLines(series);
if (!series.get('limitLines')) {
return;
}
const limitElements = {
limitLines: []
};
const limitLine = this.limitLineForSeries(series);
if (limitLine) {
limitElements.limitLines.push(limitLine);
this.limitLines.push(limitLine);
}
this.seriesLimits.set(series, limitElements);
},
clearLimitLines(series) {
const seriesLimits = this.seriesLimits.get(series);
if (seriesLimits) {
seriesLimits.limitLines.forEach(function (line) {
this.limitLines.splice(this.limitLines.indexOf(line), 1);
line.destroy();
}, this);
this.seriesLimits.delete(series);
}
},
canDraw() {
if (!this.offset.x || !this.offset.y) {
return false;
}
return true;
},
updateLimitsAndDraw() {
this.drawLimitLines();
this.scheduleDraw();
},
scheduleDraw() {
if (!this.drawScheduled) {
requestAnimationFrame(this.draw);
this.drawScheduled = true;
}
},
draw() {
this.drawScheduled = false;
if (this.isDestroyed) {
return;
}
this.drawAPI.clear();
if (this.canDraw()) {
this.updateViewport();
this.drawSeries();
this.drawRectangles();
this.drawHighlights();
}
},
updateViewport() {
const xRange = this.config.xAxis.get('displayRange');
const yRange = this.config.yAxis.get('displayRange');
if (!xRange || !yRange) {
return;
}
const dimensions = [
xRange.max - xRange.min,
yRange.max - yRange.min
];
const origin = [
this.offset.x(xRange.min),
this.offset.y(yRange.min)
];
this.drawAPI.setDimensions(
dimensions,
origin
);
},
drawSeries() {
this.lines.forEach(this.drawLine, this);
this.pointSets.forEach(this.drawPoints, this);
this.alarmSets.forEach(this.drawAlarmPoints, this);
},
drawLimitLines() {
if (this.canDraw()) {
this.updateViewport();
if (!this.drawAPI.origin) {
return;
}
Array.from(this.$refs.limitArea.children).forEach((el) => el.remove());
let limitPointOverlap = [];
this.limitLines.forEach((limitLine) => {
let limitContainerEl = this.$refs.limitArea;
limitLine.limits.forEach((limit) => {
const showLabels = this.showLabels(limit.seriesKey);
if (showLabels) {
const overlap = this.getLimitOverlap(limit, limitPointOverlap);
limitPointOverlap.push(overlap);
let limitLabelEl = this.getLimitLabel(limit, overlap);
limitContainerEl.appendChild(limitLabelEl);
}
let limitEl = this.getLimitElement(limit);
limitContainerEl.appendChild(limitEl);
}, this);
});
}
},
showLabels(seriesKey) {
return this.showLimitLineLabels.seriesKey
&& (this.showLimitLineLabels.seriesKey === seriesKey);
},
getLimitElement(limit) {
let point = {
left: 0,
top: this.drawAPI.y(limit.point.y)
};
let LimitLineClass = Vue.extend(LimitLine);
const component = new LimitLineClass({
propsData: {
point,
limit
}
});
component.$mount();
return component.$el;
},
getLimitOverlap(limit, overlapMap) {
//calculate if limit lines are too close to each other
let limitTop = this.drawAPI.y(limit.point.y);
const needsVerticalAdjustment = limitTop - CLEARANCE <= 0;
let needsHorizontalAdjustment = false;
overlapMap.forEach(value => {
let diffTop;
if (limitTop > value.overlapTop) {
diffTop = limitTop - value.overlapTop;
} else {
diffTop = value.overlapTop - limitTop;
}
//need to compare +ves to +ves and -ves to -ves
if (!needsHorizontalAdjustment
&& Math.abs(diffTop) <= CLEARANCE
&& value.needsHorizontalAdjustment !== true) {
needsHorizontalAdjustment = true;
}
});
return {
needsHorizontalAdjustment,
needsVerticalAdjustment,
overlapTop: limitTop
};
},
getLimitLabel(limit, overlap) {
let point = {
left: 0,
top: this.drawAPI.y(limit.point.y)
};
let LimitLabelClass = Vue.extend(LimitLabel);
const component = new LimitLabelClass({
propsData: {
limit: Object.assign({}, overlap, limit),
point
}
});
component.$mount();
return component.$el;
},
drawAlarmPoints(alarmSet) {
this.drawAPI.drawLimitPoints(
alarmSet.points,
alarmSet.series.get('color').asRGBAArray(),
alarmSet.series.get('markerSize')
);
},
drawPoints(chartElement) {
this.drawAPI.drawPoints(
chartElement.getBuffer(),
chartElement.color().asRGBAArray(),
chartElement.count,
chartElement.series.get('markerSize'),
chartElement.series.get('markerShape')
);
},
drawLine(chartElement, disconnected) {
this.drawAPI.drawLine(
chartElement.getBuffer(),
chartElement.color().asRGBAArray(),
chartElement.count,
disconnected
);
},
drawHighlights() {
if (this.highlights && this.highlights.length) {
this.highlights.forEach(this.drawHighlight, this);
}
},
drawHighlight(highlight) {
const points = new Float32Array([
this.offset.xVal(highlight.point, highlight.series),
this.offset.yVal(highlight.point, highlight.series)
]);
const color = highlight.series.get('color').asRGBAArray();
const pointCount = 1;
const shape = highlight.series.get('markerShape');
this.drawAPI.drawPoints(points, color, pointCount, HIGHLIGHT_SIZE, shape);
},
drawRectangles() {
if (this.rectangles) {
this.rectangles.forEach(this.drawRectangle, this);
}
},
drawRectangle(rect) {
this.drawAPI.drawSquare(
[
this.offset.x(rect.start.x),
this.offset.y(rect.start.y)
],
[
this.offset.x(rect.end.x),
this.offset.y(rect.end.y)
],
rect.color
);
}
}
};
</script>