Style Timestrip and Plan views (#3715)

* Time Strip styling WIP

- Plan activity rects now don't use corner radius if smaller than the
radius, and use a minimum width of 1;
- Visual refinements to time axis and swimlanes;
- Refactored CSS-related class names and file `swim-lane` to `swimlane`;

* Compute row span dynamically

* Remove activities only in the current plan when refreshing

* Time Strip styling WIP

- Refinement and consolidation of CSS between c-plan and c-timeline;
- CSS cleanups;

* Time Strip styling WIP

- Added calculated activity text coloring based on background fill
color;

* Fix timestrip time bounds syncing

* Unlisten for bounds

* Update name of css file

* Adds test for time system axis in timestrip

Co-authored-by: Joshi <simplyrender@gmail.com>
This commit is contained in:
Charles Hacskaylo 2021-03-01 10:15:53 -08:00 committed by GitHub
parent 3571004f5c
commit 201d622b85
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 185 additions and 104 deletions

View File

@ -1,6 +1,6 @@
<template> <template>
<div ref="plan" <div ref="plan"
class="c-plan" class="c-plan c-timeline-holder"
> >
<template v-if="viewBounds && !options.compact"> <template v-if="viewBounds && !options.compact">
<swim-lane> <swim-lane>
@ -22,10 +22,10 @@
</template> </template>
<script> <script>
import * as d3Selection from 'd3-selection';
import * as d3Scale from 'd3-scale'; import * as d3Scale from 'd3-scale';
import TimelineAxis from "../../ui/components/TimeSystemAxis.vue"; import TimelineAxis from "../../ui/components/TimeSystemAxis.vue";
import SwimLane from "@/ui/components/swim-lane/SwimLane.vue"; import SwimLane from "@/ui/components/swim-lane/SwimLane.vue";
import { getValidatedPlan } from "./util";
import Vue from "vue"; import Vue from "vue";
//TODO: UI direction needed for the following property values //TODO: UI direction needed for the following property values
@ -38,9 +38,8 @@ const RESIZE_POLL_INTERVAL = 200;
const ROW_HEIGHT = 25; const ROW_HEIGHT = 25;
const LINE_HEIGHT = 12; const LINE_HEIGHT = 12;
const MAX_TEXT_WIDTH = 300; const MAX_TEXT_WIDTH = 300;
const EDGE_ROUNDING = 10; const EDGE_ROUNDING = 5;
const DEFAULT_COLOR = 'yellow'; const DEFAULT_COLOR = '#cc9922';
const DEFAULT_TEXT_COLOR = 'white';
export default { export default {
components: { components: {
@ -72,7 +71,7 @@ export default {
}; };
}, },
mounted() { mounted() {
this.validateJSON(this.domainObject.selectFile.body); this.getPlanData(this.domainObject);
this.canvas = this.$refs.plan.appendChild(document.createElement('canvas')); this.canvas = this.$refs.plan.appendChild(document.createElement('canvas'));
this.canvas.height = 0; this.canvas.height = 0;
@ -118,14 +117,8 @@ export default {
return clientWidth - 200; return clientWidth - 200;
}, },
validateJSON(jsonString) { getPlanData(domainObject) {
try { this.planData = getValidatedPlan(domainObject);
this.json = JSON.parse(jsonString);
} catch (e) {
return false;
}
return true;
}, },
updateViewBounds() { updateViewBounds() {
this.viewBounds = this.openmct.time.bounds(); this.viewBounds = this.openmct.time.bounds();
@ -148,7 +141,8 @@ export default {
} }
}, },
clearPreviousActivities() { clearPreviousActivities() {
d3Selection.selectAll(".c-plan__contents > div").remove(); let activities = this.$el.querySelectorAll(".c-plan__contents > div");
activities.forEach(activity => activity.remove());
}, },
setDimensions() { setDimensions() {
const planHolder = this.$refs.plan; const planHolder = this.$refs.plan;
@ -231,14 +225,14 @@ export default {
return (currentRow || 0); return (currentRow || 0);
}, },
calculatePlanLayout() { calculatePlanLayout() {
let groups = Object.keys(this.json); let groups = Object.keys(this.planData);
this.groupActivities = {}; this.groupActivities = {};
groups.forEach((key, index) => { groups.forEach((key, index) => {
let activitiesByRow = {}; let activitiesByRow = {};
let currentRow = 0; let currentRow = 0;
let activities = this.json[key]; let activities = this.planData[key];
activities.forEach((activity) => { activities.forEach((activity) => {
if (this.isActivityInBounds(activity)) { if (this.isActivityInBounds(activity)) {
const currentStart = Math.max(this.viewBounds.start, activity.start); const currentStart = Math.max(this.viewBounds.start, activity.start);
@ -251,6 +245,13 @@ export default {
//TODO: Fix bug for SVG where the rectWidth is not proportional to the canvas measuredWidth of the text //TODO: Fix bug for SVG where the rectWidth is not proportional to the canvas measuredWidth of the text
const activityNameFitsRect = (rectWidth >= activityNameWidth); const activityNameFitsRect = (rectWidth >= activityNameWidth);
const textStart = (activityNameFitsRect ? rectX : rectY) + TEXT_LEFT_PADDING; const textStart = (activityNameFitsRect ? rectX : rectY) + TEXT_LEFT_PADDING;
const color = activity.color || DEFAULT_COLOR;
let textColor = '';
if (activity.textColor) {
textColor = activity.textColor;
} else if (activityNameFitsRect) {
textColor = this.getContrastingColor(color);
}
let textLines = this.getActivityDisplayText(this.canvasContext, activity.name, activityNameFitsRect); let textLines = this.getActivityDisplayText(this.canvasContext, activity.name, activityNameFitsRect);
const textWidth = textStart + this.getTextWidth(textLines[0]) + TEXT_LEFT_PADDING; const textWidth = textStart + this.getTextWidth(textLines[0]) + TEXT_LEFT_PADDING;
@ -269,8 +270,8 @@ export default {
activitiesByRow[currentRow].push({ activitiesByRow[currentRow].push({
activity: { activity: {
color: activity.color || DEFAULT_COLOR, color: color,
textColor: activity.textColor || DEFAULT_TEXT_COLOR, textColor: textColor,
name: activity.name, name: activity.name,
exceeds: { exceeds: {
start: this.xScale(this.viewBounds.start) > this.xScale(activity.start), start: this.xScale(this.viewBounds.start) > this.xScale(activity.start),
@ -279,6 +280,7 @@ export default {
}, },
textLines: textLines, textLines: textLines,
textStart: textStart, textStart: textStart,
textClass: activityNameFitsRect ? "" : "activity-label--outside-rect",
textY: textY, textY: textY,
start: rectX, start: rectX,
end: activityNameFitsRect ? rectY : textStart + textWidth, end: activityNameFitsRect ? rectY : textStart + textWidth,
@ -423,11 +425,14 @@ export default {
width = width + EDGE_ROUNDING; width = width + EDGE_ROUNDING;
} }
width = Math.max(width, 1); // Set width to a minimum of 1
// rx: don't round corners if the width of the rect is smaller than the rounding radius
this.setNSAttributesForElement(rectElement, { this.setNSAttributesForElement(rectElement, {
class: 'activity-bounds', class: 'activity-bounds',
x: item.activity.exceeds.start ? item.start - EDGE_ROUNDING : item.start, x: item.activity.exceeds.start ? item.start - EDGE_ROUNDING : item.start,
y: row, y: row,
rx: EDGE_ROUNDING, rx: (width < EDGE_ROUNDING * 2) ? 0 : EDGE_ROUNDING,
width: width, width: width,
height: String(ROW_HEIGHT), height: String(ROW_HEIGHT),
fill: activity.color fill: activity.color
@ -438,7 +443,7 @@ export default {
item.textLines.forEach((line, index) => { item.textLines.forEach((line, index) => {
let textElement = document.createElementNS('http://www.w3.org/2000/svg', 'text'); let textElement = document.createElementNS('http://www.w3.org/2000/svg', 'text');
this.setNSAttributesForElement(textElement, { this.setNSAttributesForElement(textElement, {
class: 'activity-label', class: `activity-label ${item.textClass}`,
x: item.textStart, x: item.textStart,
y: item.textY + (index * LINE_HEIGHT), y: item.textY + (index * LINE_HEIGHT),
fill: activity.textColor fill: activity.textColor
@ -449,6 +454,29 @@ export default {
svgElement.appendChild(textElement); svgElement.appendChild(textElement);
}); });
// this.addForeignElement(svgElement, activity.name, item.textStart, item.textY - LINE_HEIGHT); // this.addForeignElement(svgElement, activity.name, item.textStart, item.textY - LINE_HEIGHT);
},
cutHex(h, start, end) {
const hStr = (h.charAt(0) === '#') ? h.substring(1, 7) : h;
return parseInt(hStr.substring(start, end), 16);
},
getContrastingColor(hexColor) {
// https://codepen.io/davidhalford/pen/ywEva/
// TODO: move this into a general utility function?
const cThreshold = 130;
if (hexColor.indexOf('#') === -1) {
// We weren't given a hex color
return "#ff0000";
}
const hR = this.cutHex(hexColor, 0, 2);
const hG = this.cutHex(hexColor, 2, 4);
const hB = this.cutHex(hexColor, 4, 6);
const cBrightness = ((hR * 299) + (hG * 587) + (hB * 114)) / 1000;
return cBrightness > cThreshold ? "#000000" : "#ffffff";
} }
} }
}; };

View File

@ -1,21 +1,16 @@
.c-plan { .c-plan {
@include abs();
svg { svg {
text-rendering: geometricPrecision; text-rendering: geometricPrecision;
.activity-label, .no-activities { text {
stroke: none; stroke: none;
} }
.no-activities { .activity-label {
fill: #383838; &--outside-rect {
} fill: $colorBodyFg !important;
}
.activity-bounds { }
fill-opacity: 0.5;
}
} }
canvas { canvas {

11
src/plugins/plan/util.js Normal file
View File

@ -0,0 +1,11 @@
export function getValidatedPlan(domainObject) {
let jsonString = domainObject.selectFile.body;
let json = {};
try {
json = JSON.parse(jsonString);
} catch (e) {
return json;
}
return json;
}

View File

@ -55,7 +55,7 @@
<swim-lane :icon-class="item.type.definition.cssClass" <swim-lane :icon-class="item.type.definition.cssClass"
:min-height="item.height" :min-height="item.height"
:show-ucontents="item.domainObject.type === 'plan'" :show-ucontents="item.domainObject.type === 'plan'"
:span-rows="item.domainObject.type === 'plan'" :span-rows-count="item.rowCount"
> >
<template slot="label"> <template slot="label">
{{ item.domainObject.name }} {{ item.domainObject.name }}
@ -78,6 +78,7 @@
import ObjectView from '@/ui/components/ObjectView.vue'; import ObjectView from '@/ui/components/ObjectView.vue';
import TimelineAxis from '../../ui/components/TimeSystemAxis.vue'; import TimelineAxis from '../../ui/components/TimeSystemAxis.vue';
import SwimLane from "@/ui/components/swim-lane/SwimLane.vue"; import SwimLane from "@/ui/components/swim-lane/SwimLane.vue";
import { getValidatedPlan } from "../plan/util";
const unknownObjectType = { const unknownObjectType = {
definition: { definition: {
@ -116,6 +117,8 @@ export default {
this.composition.off('add', this.addItem); this.composition.off('add', this.addItem);
this.composition.off('remove', this.removeItem); this.composition.off('remove', this.removeItem);
this.composition.off('reorder', this.reorder); this.composition.off('reorder', this.reorder);
this.openmct.time.off("bounds", this.updateViewBounds);
}, },
mounted() { mounted() {
if (this.composition) { if (this.composition) {
@ -126,6 +129,7 @@ export default {
} }
this.getTimeSystems(); this.getTimeSystems();
this.openmct.time.on("bounds", this.updateViewBounds);
}, },
methods: { methods: {
addItem(domainObject) { addItem(domainObject) {
@ -133,6 +137,10 @@ export default {
let keyString = this.openmct.objects.makeKeyString(domainObject.identifier); let keyString = this.openmct.objects.makeKeyString(domainObject.identifier);
let objectPath = [domainObject].concat(this.objectPath.slice()); let objectPath = [domainObject].concat(this.objectPath.slice());
let viewKey = getViewKey(domainObject, this.openmct); let viewKey = getViewKey(domainObject, this.openmct);
let rowCount = 0;
if (domainObject.type === 'plan') {
rowCount = Object.keys(getValidatedPlan(domainObject)).length;
}
let height = domainObject.type === 'telemetry.plot.stacked' ? `${domainObject.composition.length * 100}px` : '100px'; let height = domainObject.type === 'telemetry.plot.stacked' ? `${domainObject.composition.length * 100}px` : '100px';
let item = { let item = {
@ -141,6 +149,7 @@ export default {
type, type,
keyString, keyString,
viewKey, viewKey,
rowCount,
height height
}; };
@ -174,6 +183,12 @@ export default {
//TODO: Some kind of translation via an offset? of current bounds to target timeSystem //TODO: Some kind of translation via an offset? of current bounds to target timeSystem
return currentBounds; return currentBounds;
},
updateViewBounds(bounds) {
let currentTimeSystem = this.timeSystems.find(item => item.timeSystem.key === this.openmct.time.timeSystem().key);
if (currentTimeSystem) {
currentTimeSystem.bounds = bounds;
}
} }
} }
}; };

View File

@ -22,6 +22,7 @@
import { createOpenMct, resetApplicationState } from "utils/testing"; import { createOpenMct, resetApplicationState } from "utils/testing";
import TimelinePlugin from "./plugin"; import TimelinePlugin from "./plugin";
import Vue from 'vue';
describe('the plugin', function () { describe('the plugin', function () {
let objectDef; let objectDef;
@ -47,7 +48,7 @@ describe('the plugin', function () {
child.style.height = '480px'; child.style.height = '480px';
element.appendChild(child); element.appendChild(child);
openmct.time.bounds({ openmct.time.timeSystem('utc', {
start: 1597160002854, start: 1597160002854,
end: 1597181232854 end: 1597181232854
}); });
@ -75,18 +76,32 @@ describe('the plugin', function () {
it('is creatable', () => { it('is creatable', () => {
expect(objectDef.creatable).toEqual(mockObject.creatable); expect(objectDef.creatable).toEqual(mockObject.creatable);
}); });
});
it('provides a timeline view', () => { describe('the view', () => {
let timelineView;
beforeEach((done) => {
const testViewObject = { const testViewObject = {
id: "test-object", id: "test-object",
type: "time-strip" type: "time-strip"
}; };
const applicableViews = openmct.objectViews.get(testViewObject); const applicableViews = openmct.objectViews.get(testViewObject);
let timelineView = applicableViews.find((viewProvider) => viewProvider.key === 'time-strip.view'); timelineView = applicableViews.find((viewProvider) => viewProvider.key === 'time-strip.view');
let view = timelineView.view(testViewObject, element);
view.show(child, true);
Vue.nextTick(done);
});
it('provides a view', () => {
expect(timelineView).toBeDefined(); expect(timelineView).toBeDefined();
}); });
it('displays a time axis', () => {
const el = element.querySelector('.c-timesystem-axis');
expect(el).toBeDefined();
});
}); });
}); });

View File

@ -1,7 +1,4 @@
.c-timeline-holder { .c-timeline-holder {
@include abs(); @include abs();
} overflow-x: hidden;
}
.c-timeline {
}

View File

@ -34,7 +34,7 @@
@import "../ui/components/object-label.scss"; @import "../ui/components/object-label.scss";
@import "../ui/components/progress-bar.scss"; @import "../ui/components/progress-bar.scss";
@import "../ui/components/search.scss"; @import "../ui/components/search.scss";
@import "../ui/components/swim-lane/swim-lane.scss"; @import "../ui/components/swim-lane/swimlane.scss";
@import "../ui/components/toggle-switch.scss"; @import "../ui/components/toggle-switch.scss";
@import "../ui/components/timesystem-axis.scss"; @import "../ui/components/timesystem-axis.scss";
@import "../ui/inspector/elements.scss"; @import "../ui/inspector/elements.scss";

View File

@ -1,10 +1,11 @@
<template> <template>
<div class="u-contents" <div class="u-contents"
:class="{'c-swim-lane': !isNested}" :class="{'c-swimlane': !isNested}"
> >
<div class="c-swim-lane__lane-label c-object-label" <div class="c-swimlane__lane-label c-object-label"
:class="{'c-swim-lane__lane-label--span-rows': spanRows, 'c-swim-lane__lane-label--span-cols': (!spanRows && !isNested)}" :class="{'c-swimlane__lane-label--span-cols': (!spanRowsCount && !isNested)}"
:style="gridRowSpan"
> >
<div v-if="iconClass" <div v-if="iconClass"
class="c-object-label__type-icon" class="c-object-label__type-icon"
@ -17,7 +18,7 @@
</div> </div>
</div> </div>
<div class="c-swim-lane__lane-object" <div class="c-swimlane__lane-object"
:style="{'min-height': minHeight}" :style="{'min-height': minHeight}"
:class="{'u-contents': showUcontents}" :class="{'u-contents': showUcontents}"
data-selectable data-selectable
@ -55,10 +56,19 @@ export default {
return false; return false;
} }
}, },
spanRows: { spanRowsCount: {
type: Boolean, type: Number,
default() { default() {
return false; return 0;
}
}
},
computed: {
gridRowSpan() {
if (this.spanRowsCount) {
return `grid-row: span ${this.spanRowsCount}`;
} else {
return '';
} }
} }
} }

View File

@ -1,27 +0,0 @@
.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;
}
}
}

View File

@ -0,0 +1,30 @@
.c-swimlane {
display: grid;
grid-template-columns: 100px 100px 1fr;
grid-column-gap: 1px;
grid-row-gap: 1px;
margin-bottom: 1px;
width: 100%;
[class*='__lane-label'] {
background: rgba($colorBodyFg, 0.2);
color: $colorBodyFg;
padding: $interiorMarginSm;
}
[class*='--span-cols'] {
grid-column: span 2;
}
[class*='--span-rows'] {
grid-row: span 4;
}
&__lane-object {
background: rgba(black, 0.1);
.c-plan {
display: contents;
}
}
}

View File

@ -1,35 +1,42 @@
.c-timesystem-axis { .c-timesystem-axis {
$h: 30px; $h: 30px;
height: $h; height: $h;
svg { svg {
text-rendering: geometricPrecision; $lineC: rgba($colorBodyFg, 0.3) !important;
width: 100%; text-rendering: geometricPrecision;
height: 100%; width: 100%;
height: 100%;
text:not(.activity) { .domain {
// Tick labels stroke: $lineC;
fill: $colorBodyFg; }
paint-order: stroke;
font-weight: bold; .tick {
stroke: $colorBodyBg; line {
stroke-linecap: butt; stroke: $lineC;
stroke-linejoin: bevel; }
stroke-width: 6px;
text {
// Tick labels
fill: $colorBodyFg;
paint-order: stroke;
font-weight: bold;
}
}
} }
}
.nowMarker { .nowMarker {
width: 2px; width: 2px;
position: absolute; position: absolute;
z-index: 10; z-index: 10;
background: gray; background: gray;
& .icon-arrow-down { & .icon-arrow-down {
font-size: large; font-size: large;
position: absolute; position: absolute;
top: -8px; top: -8px;
left: -8px; left: -8px;
}
} }
}
} }