diff --git a/index.html b/index.html index 44c727bfec..159f42a587 100644 --- a/index.html +++ b/index.html @@ -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" diff --git a/src/plugins/plan/Plan.vue b/src/plugins/plan/Plan.vue new file mode 100644 index 0000000000..f70217f515 --- /dev/null +++ b/src/plugins/plan/Plan.vue @@ -0,0 +1,455 @@ + + + + + {{ timeSystem.name }} + + + + + + + + + diff --git a/src/plugins/plan/PlanViewProvider.js b/src/plugins/plan/PlanViewProvider.js new file mode 100644 index 0000000000..de4312c5d4 --- /dev/null +++ b/src/plugins/plan/PlanViewProvider.js @@ -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: '' + }); + }, + destroy: function () { + component.$destroy(); + component = undefined; + } + }; + } + }; +} diff --git a/src/plugins/plan/plan.scss b/src/plugins/plan/plan.scss new file mode 100644 index 0000000000..05f7876eb3 --- /dev/null +++ b/src/plugins/plan/plan.scss @@ -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; + } +} diff --git a/src/plugins/plan/plugin.js b/src/plugins/plan/plugin.js new file mode 100644 index 0000000000..1dddb001a7 --- /dev/null +++ b/src/plugins/plan/plugin.js @@ -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)); + }; +} + diff --git a/src/plugins/plan/pluginSpec.js b/src/plugins/plan/pluginSpec.js new file mode 100644 index 0000000000..167231df05 --- /dev/null +++ b/src/plugins/plan/pluginSpec.js @@ -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); + }); + }); + +}); diff --git a/src/plugins/plot/vue/overlayPlot/OverlayPlotViewProvider.js b/src/plugins/plot/vue/overlayPlot/OverlayPlotViewProvider.js index 134728db25..a494c41275 100644 --- a/src/plugins/plot/vue/overlayPlot/OverlayPlotViewProvider.js +++ b/src/plugins/plot/vue/overlayPlot/OverlayPlotViewProvider.js @@ -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: '' + data() { + return { + options: { + compact: isCompact + } + }; + }, + template: '' }); }, destroy: function () { diff --git a/src/plugins/plot/vue/single/MctPlot.vue b/src/plugins/plot/vue/single/MctPlot.vue index 7dfd439c61..7ff0c8f2a2 100644 --- a/src/plugins/plot/vue/single/MctPlot.vue +++ b/src/plugins/plot/vue/single/MctPlot.vue @@ -50,7 +50,7 @@ > - - @@ -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); } } }; diff --git a/src/plugins/plot/vue/single/MctTicks.vue b/src/plugins/plot/vue/single/MctTicks.vue index bfb8c4e19d..8d7387a490 100644 --- a/src/plugins/plot/vue/single/MctTicks.vue +++ b/src/plugins/plot/vue/single/MctTicks.vue @@ -76,7 +76,7 @@ diff --git a/src/plugins/timeline/TimelineViewLayout.vue b/src/plugins/timeline/TimelineViewLayout.vue index 3025f200f8..4bd299a302 100644 --- a/src/plugins/timeline/TimelineViewLayout.vue +++ b/src/plugins/timeline/TimelineViewLayout.vue @@ -21,25 +21,160 @@ *****************************************************************************/ - - + + + + + {{ timeSystemItem.timeSystem.name }} + + + + + + + + + + + + + {{ item.domainObject.name }} + + + + + + diff --git a/src/plugins/timeline/TimelineViewProvider.js b/src/plugins/timeline/TimelineViewProvider.js index 7d968f8f9e..b851d9ab40 100644 --- a/src/plugins/timeline/TimelineViewProvider.js +++ b/src/plugins/timeline/TimelineViewProvider.js @@ -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: '' }); diff --git a/src/plugins/timeline/plugin.js b/src/plugins/timeline/plugin.js index 8d39f7b26a..25568adcf7 100644 --- a/src/plugins/timeline/plugin.js +++ b/src/plugins/timeline/plugin.js @@ -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)); diff --git a/src/plugins/timeline/pluginSpec.js b/src/plugins/timeline/pluginSpec.js index 4d69fdd5ae..ffaf2a4269 100644 --- a/src/plugins/timeline/pluginSpec.js +++ b/src/plugins/timeline/pluginSpec.js @@ -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: '' - }); - - 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)); - }); - }); - }); diff --git a/src/plugins/timeline/timeline.scss b/src/plugins/timeline/timeline.scss new file mode 100644 index 0000000000..3bc1f2c1ec --- /dev/null +++ b/src/plugins/timeline/timeline.scss @@ -0,0 +1,7 @@ +.c-timeline-holder { + @include abs(); +} + +.c-timeline { + +} diff --git a/src/styles/vue-styles.scss b/src/styles/vue-styles.scss index c3f2136715..4347c2cd39 100644 --- a/src/styles/vue-styles.scss +++ b/src/styles/vue-styles.scss @@ -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"; diff --git a/src/ui/components/ObjectView.vue b/src/ui/components/ObjectView.vue index 09d9995a6f..6a5861398f 100644 --- a/src/ui/components/ObjectView.vue +++ b/src/ui/components/ObjectView.vue @@ -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]; diff --git a/src/ui/components/TimeSystemAxis.vue b/src/ui/components/TimeSystemAxis.vue new file mode 100644 index 0000000000..d8f30e6f13 --- /dev/null +++ b/src/ui/components/TimeSystemAxis.vue @@ -0,0 +1,166 @@ + + + + + + + diff --git a/src/ui/components/swim-lane/SwimLane.vue b/src/ui/components/swim-lane/SwimLane.vue new file mode 100644 index 0000000000..3bc5c4eadb --- /dev/null +++ b/src/ui/components/swim-lane/SwimLane.vue @@ -0,0 +1,66 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/src/ui/components/swim-lane/swim-lane.scss b/src/ui/components/swim-lane/swim-lane.scss new file mode 100644 index 0000000000..a57ed95267 --- /dev/null +++ b/src/ui/components/swim-lane/swim-lane.scss @@ -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; + } + } +} diff --git a/src/plugins/timeline/timeline-axis.scss b/src/ui/components/timesystem-axis.scss similarity index 56% rename from src/plugins/timeline/timeline-axis.scss rename to src/ui/components/timesystem-axis.scss index efdf7de7c7..c09bb7e993 100644 --- a/src/plugins/timeline/timeline-axis.scss +++ b/src/ui/components/timesystem-axis.scss @@ -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;