From e54cc546c7e0db23bc31eca028198a1b6f803fd4 Mon Sep 17 00:00:00 2001 From: cho Date: Wed, 25 Sep 2019 00:36:22 -0500 Subject: [PATCH 1/2] Closes #22: add vapp static designs --- demo/src/app/app.component.html | 2 +- demo/src/app/app.component.ts | 2 +- .../components-page.component.html | 1 + .../components-page.component.less | 4 +- .../components/components-routing.module.ts | 4 + demo/src/app/components/components.module.ts | 4 + .../misc-page.component.ts | 4 +- ...isc-scrollbar-horizontal-demo.component.ts | 39 +- .../misc-scrollbar-vertical-demo.component.ts | 71 +- .../vapp-page.component.ts | 12 + .../vapp-static-demo.component.ts | 84 + .../vm-basic-demo.component.ts | 66 +- .../vm-create-demo.component.ts | 18 +- .../vm-delete-demo.component.ts | 18 +- .../constants/vapp-basic-placeholder-data.ts | 2064 +++++++++++++++++ demo/src/styles.less | 3 + src/.DS_Store | Bin 0 -> 8196 bytes src/components/connector.test.ts | 30 + src/components/connector.ts | 40 + src/components/entity-label.test.ts | 36 + src/components/entity-label.ts | 58 + src/components/isolated-network-label.test.ts | 29 + src/components/isolated-network-label.ts | 69 + src/components/label.ts | 30 +- src/components/margin.test.ts | 216 ++ src/components/margin.ts | 151 ++ src/components/scrollbar.test.ts | 2 +- src/components/scrollbar.ts | 73 +- src/components/small-connector.test.ts | 25 + src/components/small-connector.ts | 35 + src/components/vapp-edge-label.test.ts | 20 + src/components/vapp-edge-label.ts | 58 + src/components/vapp-network-list.test.ts | 61 + src/components/vapp-network-list.ts | 108 + src/components/vapp-network.test.ts | 110 + src/components/vapp-network.ts | 158 ++ src/components/vapp.test.ts | 81 + src/components/vapp.ts | 263 +++ src/components/vm-and-vnic-list.test.ts | 204 ++ src/components/vm-and-vnic-list.ts | 113 + src/components/vm.test.ts | 16 +- src/components/vm.ts | 14 +- src/components/vnic.test.ts | 43 + src/components/vnic.ts | 56 + src/constants/dimensions.ts | 13 + src/constants/styles.ts | 10 + 46 files changed, 4379 insertions(+), 139 deletions(-) create mode 100644 demo/src/app/components/vapp-page-component/vapp-page.component.ts create mode 100644 demo/src/app/components/vapp-static-demo-component/vapp-static-demo.component.ts create mode 100644 demo/src/app/constants/vapp-basic-placeholder-data.ts create mode 100644 src/.DS_Store create mode 100644 src/components/connector.test.ts create mode 100644 src/components/connector.ts create mode 100644 src/components/entity-label.test.ts create mode 100644 src/components/entity-label.ts create mode 100644 src/components/isolated-network-label.test.ts create mode 100644 src/components/isolated-network-label.ts create mode 100644 src/components/margin.test.ts create mode 100644 src/components/margin.ts create mode 100644 src/components/small-connector.test.ts create mode 100644 src/components/small-connector.ts create mode 100644 src/components/vapp-edge-label.test.ts create mode 100644 src/components/vapp-edge-label.ts create mode 100644 src/components/vapp-network-list.test.ts create mode 100644 src/components/vapp-network-list.ts create mode 100644 src/components/vapp-network.test.ts create mode 100644 src/components/vapp-network.ts create mode 100644 src/components/vapp.test.ts create mode 100644 src/components/vapp.ts create mode 100644 src/components/vm-and-vnic-list.test.ts create mode 100644 src/components/vm-and-vnic-list.ts create mode 100644 src/components/vnic.test.ts create mode 100644 src/components/vnic.ts create mode 100644 src/constants/styles.ts diff --git a/demo/src/app/app.component.html b/demo/src/app/app.component.html index 9e402a9..fa1c99a 100644 --- a/demo/src/app/app.component.html +++ b/demo/src/app/app.component.html @@ -1,5 +1,5 @@ diff --git a/demo/src/app/app.component.ts b/demo/src/app/app.component.ts index d938432..2267106 100644 --- a/demo/src/app/app.component.ts +++ b/demo/src/app/app.component.ts @@ -3,7 +3,7 @@ import { Component } from '@angular/core'; @Component({ selector: 'app-root', template: require('./app.component.html'), - styles: [require('./app.component.less')] + styles: [require('../styles.less'), require('./app.component.less')] }) export class AppComponent { title = 'demo'; diff --git a/demo/src/app/components/components-page/components-page.component.html b/demo/src/app/components/components-page/components-page.component.html index a18e80c..715c169 100644 --- a/demo/src/app/components/components-page/components-page.component.html +++ b/demo/src/app/components/components-page/components-page.component.html @@ -3,6 +3,7 @@ diff --git a/demo/src/app/components/components-page/components-page.component.less b/demo/src/app/components/components-page/components-page.component.less index f71d695..3b04cdd 100644 --- a/demo/src/app/components/components-page/components-page.component.less +++ b/demo/src/app/components/components-page/components-page.component.less @@ -19,7 +19,7 @@ background-color: #E3E8E8 } .nav-link.active { - background-color: lightgray !important; - font-weight: 500 !important; + background-color: lightgray; + font-weight: 500; } } diff --git a/demo/src/app/components/components-routing.module.ts b/demo/src/app/components/components-routing.module.ts index 49e541a..7227dba 100644 --- a/demo/src/app/components/components-routing.module.ts +++ b/demo/src/app/components/components-routing.module.ts @@ -2,6 +2,7 @@ import { NgModule } from '@angular/core'; import { RouterModule } from '@angular/router'; import { ComponentsPageComponent } from './components-page/components-page.component'; import { VmPageComponent } from './vm-page-component/vm-page.component'; +import { VappPageComponent } from './vapp-page-component/vapp-page.component'; import { MiscPageComponent } from './misc-page-component/misc-page.component'; const routes = [ @@ -17,6 +18,9 @@ const routes = [ { path: 'vm', component: VmPageComponent }, + { + path: 'vapp', component: VappPageComponent + }, { path: 'misc', component: MiscPageComponent } diff --git a/demo/src/app/components/components.module.ts b/demo/src/app/components/components.module.ts index 1d79e24..a4421bf 100644 --- a/demo/src/app/components/components.module.ts +++ b/demo/src/app/components/components.module.ts @@ -9,6 +9,8 @@ import { DemoComponent } from './demo-component/demo.component'; import { VmPageComponent } from './vm-page-component/vm-page.component'; import { VmCreateDemoComponent } from './vm-create-demo-component/vm-create-demo.component'; import { VmDeleteDemoComponent } from './vm-delete-demo-component/vm-delete-demo.component'; +import { VappPageComponent } from './vapp-page-component/vapp-page.component'; +import { VappStaticDemoComponent } from './vapp-static-demo-component/vapp-static-demo.component'; import { MiscPageComponent } from './misc-page-component/misc-page.component'; import { MiscScrollbarHorizontalDemoComponent } from './misc-scrollbar-demo-component/misc-scrollbar-horizontal-demo.component'; @@ -23,6 +25,8 @@ import { MiscScrollbarVerticalDemoComponent } from VmBasicDemoComponent, ComponentsPageComponent, DemoComponent, + VappPageComponent, + VappStaticDemoComponent, MiscPageComponent, MiscScrollbarHorizontalDemoComponent, MiscScrollbarVerticalDemoComponent diff --git a/demo/src/app/components/misc-page-component/misc-page.component.ts b/demo/src/app/components/misc-page-component/misc-page.component.ts index 5a9481e..814efd4 100644 --- a/demo/src/app/components/misc-page-component/misc-page.component.ts +++ b/demo/src/app/components/misc-page-component/misc-page.component.ts @@ -1,9 +1,9 @@ import { Component } from '@angular/core'; @Component({ - selector: 'other-page', + selector: 'misc-page', template: ` -
+
diff --git a/demo/src/app/components/misc-scrollbar-demo-component/misc-scrollbar-horizontal-demo.component.ts b/demo/src/app/components/misc-scrollbar-demo-component/misc-scrollbar-horizontal-demo.component.ts index 5ef0c59..ba41ad1 100644 --- a/demo/src/app/components/misc-scrollbar-demo-component/misc-scrollbar-horizontal-demo.component.ts +++ b/demo/src/app/components/misc-scrollbar-demo-component/misc-scrollbar-horizontal-demo.component.ts @@ -7,8 +7,8 @@ import { DEFAULT_SCROLLBAR_THICKNESS } from '../../../../../src/constants/dimens @Component({ selector: 'misc-scrollbar-horizontal-demo', - template: ` - ` }) @@ -26,7 +26,7 @@ export class MiscScrollbarHorizontalDemoComponent implements AfterViewInit { proj.activeLayer.applyMatrix = false; this.demo.backgroundColor = CANVAS_BACKGROUND_COLOR; const view = paper.view; - const canvas = this.demo.canvas.nativeElement; + const canvas = paper.view.element; const VIEW_PADDING = 30; // create content @@ -37,13 +37,13 @@ export class MiscScrollbarHorizontalDemoComponent implements AfterViewInit { : i % 15 === 0 && 'fizzbuzz' || i % 3 === 0 && 'fizz' || i % 5 === 0 && 'buzz' || i; content.addChildren([ new paper.Path.Circle({ - position: new paper.Point((100 + 15) * i + 50, view.center.y - 15), + position: new paper.Point((100 + 15) * i + 50, view.center.y), radius: 50, strokeWidth: 1, strokeColor: LIGHT_GREY }), new paper.PointText({ - point: new paper.Point((100 + 15) * i + 50, view.center.y + 10 - 15), + point: new paper.Point((100 + 15) * i + 50, view.center.y + 10), content: textContent, fillColor: LIGHT_GREY, fontSize: 25, @@ -54,21 +54,24 @@ export class MiscScrollbarHorizontalDemoComponent implements AfterViewInit { content.translate(new paper.Point(VIEW_PADDING, 0)); // create scrollbar - const scrollbar = new ScrollbarComponent( - { content: content, containerBounds: view.bounds, contentOffsetEnd: VIEW_PADDING }, - new paper.Point(VIEW_PADDING, view.bounds.bottom - VIEW_PADDING - DEFAULT_SCROLLBAR_THICKNESS), - view.bounds.width - VIEW_PADDING * 2, - 'horizontal' + const scrollbar = new ScrollbarComponent({ + content: content, + containerBounds: view.bounds, + contentOffsetEnd: VIEW_PADDING + }, + new paper.Point(VIEW_PADDING, view.size.height - DEFAULT_SCROLLBAR_THICKNESS - 10), + view.bounds.width - VIEW_PADDING * 2 ); + if (scrollbar.isEnabled) { + canvas.onmouseenter = scrollbar.containerMouseEnter; + canvas.onmouseleave = scrollbar.containerMouseLeave; + + // add scroll listening. paper doesn't have a wheel event handler + canvas.onwheel = (event: WheelEvent) => { + scrollbar.onScroll(event); + }; + } - // add scroll listening. paper doesn't have a wheel event handler - canvas.onwheel = (event: WheelEvent) => { - scrollbar.onScroll(event); - }; - // paper tools are global, so specific tools need to be activated when a different view is active - view.onMouseEnter = () => { - scrollbar.activateDefaultTool(); - }; } run() { diff --git a/demo/src/app/components/misc-scrollbar-demo-component/misc-scrollbar-vertical-demo.component.ts b/demo/src/app/components/misc-scrollbar-demo-component/misc-scrollbar-vertical-demo.component.ts index bd75978..3854b87 100644 --- a/demo/src/app/components/misc-scrollbar-demo-component/misc-scrollbar-vertical-demo.component.ts +++ b/demo/src/app/components/misc-scrollbar-demo-component/misc-scrollbar-vertical-demo.component.ts @@ -1,15 +1,14 @@ import { AfterViewInit, Component, ViewChild } from '@angular/core'; import * as paper from 'paper'; import { DemoComponent } from '../demo-component/demo.component'; -import { LIGHT_GREY, CANVAS_BACKGROUND_COLOR, VAPP_BACKGROUND_COLOR } from '../../../../../src/constants/colors'; +import { LIGHT_GREY, CANVAS_BACKGROUND_COLOR } from '../../../../../src/constants/colors'; import { ScrollbarComponent } from '../../../../../src/components/scrollbar'; -import { DEFAULT_SCROLLBAR_THICKNESS } from '../../../../../src/constants/dimensions'; @Component({ selector: 'misc-scrollbar-vertical-demo', template: ` ` }) export class MiscScrollbarVerticalDemoComponent implements AfterViewInit { @@ -26,7 +25,7 @@ export class MiscScrollbarVerticalDemoComponent implements AfterViewInit { proj.activeLayer.applyMatrix = false; this.demo.backgroundColor = CANVAS_BACKGROUND_COLOR; const view = paper.view; - const canvas = this.demo.canvas.nativeElement; + const canvas = paper.view.element; const VIEW_PADDING = 30; // create content @@ -54,57 +53,25 @@ export class MiscScrollbarVerticalDemoComponent implements AfterViewInit { content.translate(new paper.Point(0, VIEW_PADDING)); // create scrollbar - const scrollbar = new ScrollbarComponent( - { content: content, containerBounds: view.bounds, contentOffsetEnd: VIEW_PADDING }, - new paper.Point(view.bounds.right - VIEW_PADDING - DEFAULT_SCROLLBAR_THICKNESS, VIEW_PADDING), + const scrollbar = new ScrollbarComponent({ + content: content, + containerBounds: view.bounds, + contentOffsetEnd: VIEW_PADDING + }, + new paper.Point(view.bounds.right - VIEW_PADDING, VIEW_PADDING), view.bounds.height - VIEW_PADDING * 2, - 'vertical'); + 'vertical' + ); + if (scrollbar.isEnabled) { + canvas.onmouseenter = scrollbar.containerMouseEnter; + canvas.onmouseleave = scrollbar.containerMouseLeave; - // add scroll listening. paper doesn't have a wheel event handler - canvas.onwheel = (event: WheelEvent) => { - scrollbar.onScroll(event); - }; - // paper tools are global, so specific tools need to be activated when a different view is active - view.onMouseEnter = () => { - scrollbar.activateDefaultTool(); - }; - - scrollbar.getScrollbar().fillColor = 'red'; - scrollbar.getTrack().fillColor = 'blue'; - - // set up custom scrollbar - const customScrollbar = new paper.Path.Rectangle({ - point: new paper.Point(-6.5, 0), - size: new paper.Size(15, 15), - pivot: new paper.Point(0, 0), - radius: 15 / 2, - fillColor: LIGHT_GREY - }); - customScrollbar.remove(); - scrollbar.setScrollbar(customScrollbar); - - // set up custom track - const customTrack = new paper.Path.Rectangle({ - point: new paper.Point(0, 0), - size: new paper.Size(2, view.bounds.height - VIEW_PADDING * 2), - fillColor: VAPP_BACKGROUND_COLOR - }); - customTrack.remove(); - scrollbar.setTrack(customTrack); + // add scroll listening. paper doesn't have a wheel event handler + canvas.onwheel = (event: WheelEvent) => { + scrollbar.onScroll(event); + }; + } - // set custom Effects - (scrollbar.getScrollbar() as paper.Path).opacity = 1; - scrollbar.disableDefaultEffects(); - scrollbar.setCustomEffects({ - setActive: () => { - (scrollbar.getScrollbar() as paper.Path).fillColor = 'DeepSkyBlue'; - }, - setNormal: () => { - (scrollbar.getScrollbar() as any).tweenTo({ - fillColor: LIGHT_GREY - }, 250); - } - }); } run() { diff --git a/demo/src/app/components/vapp-page-component/vapp-page.component.ts b/demo/src/app/components/vapp-page-component/vapp-page.component.ts new file mode 100644 index 0000000..5e42f3b --- /dev/null +++ b/demo/src/app/components/vapp-page-component/vapp-page.component.ts @@ -0,0 +1,12 @@ +import { Component } from '@angular/core'; + +@Component({ + selector: 'vapp-demo', + template: ` +
+ +
+ ` +}) +export class VappPageComponent { +} diff --git a/demo/src/app/components/vapp-static-demo-component/vapp-static-demo.component.ts b/demo/src/app/components/vapp-static-demo-component/vapp-static-demo.component.ts new file mode 100644 index 0000000..633f312 --- /dev/null +++ b/demo/src/app/components/vapp-static-demo-component/vapp-static-demo.component.ts @@ -0,0 +1,84 @@ +import { AfterViewInit, Component, ViewChild } from '@angular/core'; +import * as paper from 'paper'; +import { DemoComponent } from '../demo-component/demo.component'; +import { VappData, VappComponent } from '../../../../../src/components/vapp'; +import { placeholderArrayOfVappData } from '../../constants/vapp-basic-placeholder-data'; +import { ScrollbarComponent } from '../../../../../src/components/scrollbar'; +import { CANVAS_BACKGROUND_COLOR } from '../../../../../src/constants/colors'; +import { CONNECTOR_RADIUS, DEFAULT_SCROLLBAR_THICKNESS } from '../../../../../src/constants/dimensions'; + +@Component({ + selector: 'vapp-static-demo', + template: ` + + ` }) +export class VappStaticDemoComponent implements AfterViewInit { + + @ViewChild(DemoComponent) + demo: DemoComponent; + + ngAfterViewInit() { + // sets up Paper Project + const proj = this.demo.getProject(); + proj.activate(); + const view = paper.view; + const canvas = paper.view.element; + this.demo.backgroundColor = CANVAS_BACKGROUND_COLOR; + + const VIEW_PADDING = 30; + const DEMO_VAPP_TOP_ALIGNMENT = 59; + const VERTICAL_POSITION = VIEW_PADDING + DEMO_VAPP_TOP_ALIGNMENT + CONNECTOR_RADIUS; + const vapps: Array = placeholderArrayOfVappData; + + const content = new paper.Group({ applyMatrix: false }); + // create origin paper Item for vapps to base position from + const origin = new paper.Path.Circle({ + position: new paper.Point(VIEW_PADDING, VERTICAL_POSITION), + radius: 0, + parent: content + }); + + // create vapps + vapps.forEach(vappData => { + const position = new paper.Point(content.lastChild.bounds.right, VERTICAL_POSITION); + content.addChild(new VappComponent(vappData, position)); + }); + (content.lastChild as VappComponent).margin.right = 0; + + // create view horizontal scrollbar + const horizontalScrollbar = new ScrollbarComponent({ + content: content, + containerBounds: view.bounds, + contentOffsetEnd: VIEW_PADDING + }, + new paper.Point(VIEW_PADDING, view.size.height - DEFAULT_SCROLLBAR_THICKNESS - 10), + view.bounds.width - VIEW_PADDING * 2, + 'horizontal' + ); + if (horizontalScrollbar.isEnabled) { + canvas.onmouseenter = horizontalScrollbar.containerMouseEnter; + canvas.onmouseleave = horizontalScrollbar.containerMouseLeave; + } + + // add scroll listening. paper doesn't have a wheel event handler + canvas.onwheel = (event: WheelEvent) => { + // horizontal scrolling sent to horizontal scrollbar + if (Math.abs(event.deltaX) > Math.abs(event.deltaY)) { + horizontalScrollbar.onScroll(event); + } else { + // vertical scrolling sent to any scrollable vapp that's active/hovered + content.children.forEach(item => { + if (item instanceof VappComponent && item.isScrollable) { + item.setScrollListening(event); + } + }); + } + }; + + // TODO: keydown 'left' and 'right' should always go to horizontalScrollbar. keydown 'up' and 'down' to should go to + // any scrollable vapp that's active/hovered. can try handling with a paper tools service and/or tool stack + + // TODO: make sure 'Roboto' font loading finishes before canvas elements are rendered + } +} diff --git a/demo/src/app/components/vm-basic-demo-component/vm-basic-demo.component.ts b/demo/src/app/components/vm-basic-demo-component/vm-basic-demo.component.ts index 17d7669..c909c9b 100644 --- a/demo/src/app/components/vm-basic-demo-component/vm-basic-demo.component.ts +++ b/demo/src/app/components/vm-basic-demo-component/vm-basic-demo.component.ts @@ -21,69 +21,91 @@ export class VmBasicDemoComponent implements AfterViewInit { proj1.activate(); // tslint:disable-next-line new VmComponent({ - name: 'ubuntu', uuid: '', - operatingSystem: 'ubuntu64Guest' + name: 'ubuntu', + vapp_uuid: '', + operatingSystem: 'ubuntu64Guest', + vnics: [] }, new paper.Point(15, 15), true); // tslint:disable-next-line new VmComponent({ - name: 'fedora', uuid: '', - operatingSystem: 'fedora64Guest' + name: 'fedora', + vapp_uuid: '', + operatingSystem: 'fedora64Guest', + vnics: [] }, new paper.Point(15, 55), true); // tslint:disable-next-line new VmComponent({ - name: 'windows', uuid: '', - operatingSystem: 'windows7Guest' + name: 'windows', + vapp_uuid: '', + operatingSystem: 'windows7Guest', + vnics: [] }, new paper.Point(15, 95), true); // tslint:disable-next-line new VmComponent({ - name: 'windows xp', uuid: '', - operatingSystem: 'winXPHomeGuest' + name: 'windows xp', + vapp_uuid: '', + operatingSystem: 'winXPHomeGuest', + vnics: [] }, new paper.Point(15, 135), true); // tslint:disable-next-line new VmComponent({ - name: 'debian', uuid: '', - operatingSystem: 'debian8Guest' + name: 'debian', + vapp_uuid: '', + operatingSystem: 'debian8Guest', + vnics: [] }, new paper.Point(15, 175), true); // tslint:disable-next-line new VmComponent({ - name: 'redhat', uuid: '', - operatingSystem: 'redhatGuest' + name: 'redhat', + vapp_uuid: '', + operatingSystem: 'redhatGuest', + vnics: [] }, new paper.Point(15, 215), true); // tslint:disable-next-line new VmComponent({ - name: 'generic linux', uuid: '', - operatingSystem: 'other24xLinux64Guest' + name: 'generic linux', + vapp_uuid: '', + operatingSystem: 'other24xLinux64Guest', + vnics: [] }, new paper.Point(15, 255), true); // tslint:disable-next-line new VmComponent({ - name: 'centos', uuid: '', - operatingSystem: 'centos64Guest' + name: 'centos', + vapp_uuid: '', + operatingSystem: 'centos64Guest', + vnics: [] }, new paper.Point(15, 295), true); // tslint:disable-next-line new VmComponent({ - name: 'free bsd', uuid: '', - operatingSystem: 'freebsd64Guest' + name: 'free bsd', + vapp_uuid: '', + operatingSystem: 'freebsd64Guest', + vnics: [] }, new paper.Point(15, 335), true); // tslint:disable-next-line new VmComponent({ - name: 'core os', uuid: '', - operatingSystem: 'coreos64Guest' + name: 'core os', + vapp_uuid: '', + operatingSystem: 'coreos64Guest', + vnics: [] }, new paper.Point(15, 375), true); // tslint:disable-next-line new VmComponent({ - name: 'generic operating system with long name', uuid: '', - operatingSystem: 'other' as OperatingSystem + name: 'generic operating system with long name', + vapp_uuid: '', + operatingSystem: 'other' as OperatingSystem, + vnics: [] }, new paper.Point(15, 415), true); } diff --git a/demo/src/app/components/vm-create-demo-component/vm-create-demo.component.ts b/demo/src/app/components/vm-create-demo-component/vm-create-demo.component.ts index 6eb9a1a..2aaf684 100644 --- a/demo/src/app/components/vm-create-demo-component/vm-create-demo.component.ts +++ b/demo/src/app/components/vm-create-demo-component/vm-create-demo.component.ts @@ -23,19 +23,25 @@ export class VmCreateDemoComponent implements AfterViewInit { const proj = this.demo.getProject(); proj.activate(); this.vmOne = new VmComponent({ - name: 'fedora', uuid: '', - operatingSystem: 'fedora64Guest' + name: 'fedora', + vapp_uuid: '', + operatingSystem: 'redhatGuest', + vnics: [] }, new paper.Point(15, 15)); this.vmTwo = new VmComponent({ - name: 'redhat linux vm', uuid: '', - operatingSystem: 'redhatGuest' + name: 'redhat linux vm', + vapp_uuid: '', + operatingSystem: 'windows7Guest', + vnics: [] }, new paper.Point(15, 55)); this.vmThree = new VmComponent({ - name: 'centos vm with a really long name', uuid: '', - operatingSystem: 'centos64Guest' + name: 'centos vm with a really long name', + vapp_uuid: '', + operatingSystem: 'centos64Guest', + vnics: [] }, new paper.Point(15, 95)); } diff --git a/demo/src/app/components/vm-delete-demo-component/vm-delete-demo.component.ts b/demo/src/app/components/vm-delete-demo-component/vm-delete-demo.component.ts index 589904e..4ccf66c 100644 --- a/demo/src/app/components/vm-delete-demo-component/vm-delete-demo.component.ts +++ b/demo/src/app/components/vm-delete-demo-component/vm-delete-demo.component.ts @@ -23,19 +23,25 @@ export class VmDeleteDemoComponent implements AfterViewInit { const proj = this.demo.getProject(); proj.activate(); this.vmOne = new VmComponent({ - name: 'fedora', uuid: '', - operatingSystem: 'fedora64Guest' + name: 'fedora', + vapp_uuid: '', + operatingSystem: 'fedora64Guest', + vnics: [] }, new paper.Point(15, 15), true); this.vmTwo = new VmComponent({ - name: 'redhat linux vm', uuid: '', - operatingSystem: 'redhatGuest' + name: 'redhat linux vm', + vapp_uuid: '', + operatingSystem: 'redhatGuest', + vnics: [] }, new paper.Point(15, 55), true); this.vmThree = new VmComponent({ - name: 'centos vm with a really long name', uuid: '', - operatingSystem: 'centos64Guest' + name: 'centos vm with a really long name', + vapp_uuid: '', + operatingSystem: 'centos64Guest', + vnics: [] }, new paper.Point(15, 95), true); } diff --git a/demo/src/app/constants/vapp-basic-placeholder-data.ts b/demo/src/app/constants/vapp-basic-placeholder-data.ts new file mode 100644 index 0000000..0024156 --- /dev/null +++ b/demo/src/app/constants/vapp-basic-placeholder-data.ts @@ -0,0 +1,2064 @@ +import { VappData } from '../../../../src/components/vapp'; + +/** + * Placeholder vApp data for the Vapp Static Demo + */ + + // 0. nat-routed vapp network + // 1. isolated vapp network + // 2. multiple isolated vapp networks + // 3. long label name + // 4. no vapp network + // 5. vapp network with no attached vms or vnics + // 6. vm with multiple unattached vnics + // 7. max amount of vnics + // 8. no vapp network or vnics + // 9. attached vnic that is disconnected + // 10. multiple nat-routed vapp networks + // 11. long vms list with scrollbar + // 12. long vms list and nat-routed vapp network with scrollbar + // 13. multiple vms with max amount of vnics - width edge case + // 14. many vms attached to their own network - width edge case + // 15. variations for vnics on multiple vapp networks + // 16. variations for unattached vnics + // 17. nat-routed vApp network with no attached vms and vnics + // 18. vapp with no vapp networks or vms +export const placeholderArrayOfVappData: Array = [ + // 0. nat-routed vapp network + { + uuid: '', + name: 'Coke RES & BURST', + vapp_networks: [ + { + uuid: '0', + name: '172.16.55.0 Failover Network', + vapp_uuid: '', + fence_mode: 'NAT_ROUTED' + } + ], + vms: [ + { + uuid: '', + name: 'lin-hytrust-01', + vapp_uuid: '', + operatingSystem: 'windows7Guest', + vnics: [ + { + vnic_id: 0, + network_name: '172.16.55.0 Failover Network', + is_connected: true + } + ] + }, + { + uuid: '', + name: 'Alert Resource Non Regression VM', + vapp_uuid: '', + operatingSystem: 'windows7Guest', + vnics: [ + { + vnic_id: 1, + network_name: '172.16.55.0 Failover Network', + is_connected: true + } + ] + }, + { + uuid: '', + name: 'AutomatedSecurityTest1', + vapp_uuid: '', + operatingSystem: 'windows7Guest', + vnics: [ + { + vnic_id: 2, + network_name: '172.16.55.0 Failover Network', + is_connected: true + } + ] + }, + { + uuid: '', + name: 'CatalogResourceNonRegression1', + vapp_uuid: '', + operatingSystem: 'ubuntu64Guest', + vnics: [ + { + vnic_id: 3, + network_name: '172.16.55.0 Failover Network', + is_connected: true + } + ] + }, + { + uuid: '', + name: 'lin-hytrust-01', + vapp_uuid: '', + operatingSystem: 'windows7Guest', + vnics: [ + { + vnic_id: 4, + network_name: '172.16.55.0 Failover Network', + is_connected: true + } + ] + }, + { + uuid: '', + name: 'windows-as-01', + vapp_uuid: '', + operatingSystem: 'ubuntu64Guest', + vnics: [ + { + vnic_id: 5, + network_name: '172.16.55.0 Failover Network', + is_connected: true + } + ] + } + ] + }, + // 1. isolated vapp network + { + uuid: '', + name: 'BC Test vApp', + vapp_networks: [ + { + uuid: '0', + name: 'JTRAN 172.16.100.0/24', + vapp_uuid: '', + fence_mode: 'ISOLATED' + } + ], + vms: [ + { + uuid: '', + name: 'AutomatedSecurityTest1', + vapp_uuid: '', + operatingSystem: 'windows7Guest', + vnics: [ + { + vnic_id: 0, + network_name: 'JTRAN 172.16.100.0/24', + is_connected: true + } + ] + } + ] + }, + // 2. multiple isolated vapp networks + { + uuid: '', + name: 'BC Test vApp', + vapp_networks: [ + { + uuid: '0', + name: 'JTRAN 172.16.100.0/24', + vapp_uuid: '', + fence_mode: 'ISOLATED' + }, + { + uuid: '1', + name: 'JTRAN 172.16.100.0/24 2', + vapp_uuid: '', + fence_mode: 'ISOLATED' + } + ], + vms: [ + { + uuid: '', + name: 'AutomatedSecurityTest1', + vapp_uuid: '', + operatingSystem: 'windows7Guest', + vnics: [ + { + vnic_id: 0, + network_name: 'JTRAN 172.16.100.0/24', + is_connected: true + }, + { + vnic_id: 0, + network_name: 'JTRAN 172.16.100.0/24 2', + is_connected: true + } + ] + } + ] + }, + // 3. long label name + { + uuid: '', + name: 'BillingResourceNonRegressionoooo', + vapp_networks: [ + { + uuid: '0', + name: 'A', + vapp_uuid: '', + fence_mode: 'BRIDGED' + }, + { + uuid: '1', + name: 'B', + vapp_uuid: '', + fence_mode: 'BRIDGED' + } + ], + vms: [ + { + uuid: '', + name: 'windows-as-01', + vapp_uuid: '', + operatingSystem: 'ubuntu64Guest', + vnics: [ + { + vnic_id: 0, + network_name: 'A', + is_connected: true + }, + { + vnic_id: 1, + network_name: 'B', + is_connected: true + } + ] + } + ] + }, + // 4. no vapp network + { + uuid: '', + name: 'Delete me build', + vapp_networks: [], + vms: [ + { + uuid: '', + name: 'Delete me VMs Lin', + vapp_uuid: '', + operatingSystem: 'windows7Guest', + vnics: [ + { + vnic_id: 0, + network_name: '', + is_connected: false + } + ] + } + ] + }, + // 5. vapp network with no attached vms or vnics + { + uuid: '', + name: 'Coke RES & BURST', + vapp_networks: [ + { + uuid: '0', + name: 'A', + vapp_uuid: '', + fence_mode: 'BRIDGED' + } + ], + vms: [] + }, + // 6. vm with multiple unattached vnics + { + uuid: '', + name: 'BC Test vApp', + vapp_networks: [ + { + uuid: '0', + name: 'A', + vapp_uuid: '', + fence_mode: 'BRIDGED' + } + ], + vms: [ + { + uuid: '', + name: 'AutomatedSecurityTest1', + vapp_uuid: '', + operatingSystem: 'windows7Guest', + vnics: [ + { + vnic_id: 0, + network_name: 'A', + is_connected: true + }, + { + vnic_id: 1, + network_name: '', + is_connected: false + }, + { + vnic_id: 2, + network_name: '', + is_connected: false + } + ] + } + ] + }, + // 7. max amount of vnics + { + uuid: '', + name: 'BC Test vApp', + vapp_networks: [ + { + uuid: '0', + name: 'A', + vapp_uuid: '', + fence_mode: 'BRIDGED' + } + ], + vms: [ + { + uuid: '', + name: 'AutomatedSecurityTest1', + vapp_uuid: '', + operatingSystem: 'windows7Guest', + vnics: [ + { + vnic_id: 0, + network_name: 'A', + is_connected: true + }, + { + vnic_id: 1, + network_name: '', + is_connected: false + }, + { + vnic_id: 2, + network_name: '', + is_connected: false + }, + { + vnic_id: 3, + network_name: '', + is_connected: false + }, + { + vnic_id: 4, + network_name: '', + is_connected: false + }, + { + vnic_id: 5, + network_name: '', + is_connected: false + }, + { + vnic_id: 6, + network_name: '', + is_connected: false + }, + { + vnic_id: 7, + network_name: '', + is_connected: false + }, + { + vnic_id: 8, + network_name: '', + is_connected: false + }, + { + vnic_id: 9, + network_name: '', + is_connected: false + } + ] + } + ] + }, + // 8. no vapp network or vnics + { + uuid: '', + name: 'BC Test vApp', + vapp_networks: [], + vms: [ + { + uuid: '0', + name: 'AutomatedSecurityTest1', + vapp_uuid: '', + operatingSystem: 'windows7Guest', + vnics: [] + } + ] + }, + // 9. attached vnic that is disconnected + { + uuid: '', + name: 'BC Test vApp', + vapp_networks: [ + { + uuid: '0', + name: 'A', + vapp_uuid: '', + fence_mode: 'BRIDGED' + } + ], + vms: [ + { + uuid: '', + name: 'AutomatedSecurityTest1', + vapp_uuid: '', + operatingSystem: 'windows7Guest', + vnics: [ + { + vnic_id: 0, + network_name: 'A', + is_connected: false + } + ] + } + ] + }, + // 10. multiple nat-routed vapp networks + { + uuid: '', + name: 'Coke RES & BURST', + vapp_networks: [ + { + uuid: '0', + name: '172.16.55.0 Failover Network 1', + vapp_uuid: '', + fence_mode: 'NAT_ROUTED' + }, + { + uuid: '1', + name: '172.16.55.0 Failover Network 2', + vapp_uuid: '', + fence_mode: 'NAT_ROUTED' + }, + { + uuid: '2', + name: '172.16.55.0 Failover Network 3', + vapp_uuid: '', + fence_mode: 'NAT_ROUTED' + } + ], + vms: [ + { + uuid: '', + name: 'lin-hytrust-01', + vapp_uuid: '', + operatingSystem: 'windows7Guest', + vnics: [ + { + vnic_id: 0, + network_name: '172.16.55.0 Failover Network 1', + is_connected: true + }, + { + vnic_id: 1, + network_name: '172.16.55.0 Failover Network 2', + is_connected: true + }, + { + vnic_id: 2, + network_name: '172.16.55.0 Failover Network 3', + is_connected: true + } + ] + }, + { + uuid: '', + name: 'lin-hytrust-01', + vapp_uuid: '', + operatingSystem: 'windows7Guest', + vnics: [ + { + vnic_id: 0, + network_name: '172.16.55.0 Failover Network 1', + is_connected: true + }, + { + vnic_id: 1, + network_name: '172.16.55.0 Failover Network 2', + is_connected: true + }, + { + vnic_id: 2, + network_name: '172.16.55.0 Failover Network 3', + is_connected: true + } + ] + }, + { + uuid: '', + name: 'lin-hytrust-01', + vapp_uuid: '', + operatingSystem: 'windows7Guest', + vnics: [ + { + vnic_id: 0, + network_name: '172.16.55.0 Failover Network 1', + is_connected: true + }, + { + vnic_id: 1, + network_name: '172.16.55.0 Failover Network 2', + is_connected: true + }, + { + vnic_id: 2, + network_name: '172.16.55.0 Failover Network 3', + is_connected: true + } + ] + } + ] + }, + // 11. long vms list with scrollbar + { + uuid: '', + name: 'Coke RES & BURST', + vapp_networks: [ + { + uuid: '0', + name: 'A', + vapp_uuid: '', + fence_mode: 'BRIDGED' + } + ], + vms: [ + { + uuid: '', + name: 'Alert Resource Non Regression VM', + vapp_uuid: '', + operatingSystem: 'windows7Guest', + vnics: [ + { + vnic_id: 0, + network_name: 'A', + is_connected: true + } + ] + }, + { + uuid: '', + name: 'AutomatedSecurityTest1', + vapp_uuid: '', + operatingSystem: 'windows7Guest', + vnics: [ + { + vnic_id: 0, + network_name: 'A', + is_connected: true + } + ] + }, + { + uuid: '', + name: 'CatalogResourceNonRegression1', + vapp_uuid: '', + operatingSystem: 'ubuntu64Guest', + vnics: [ + { + vnic_id: 0, + network_name: 'A', + is_connected: true + } + ] + }, + { + uuid: '', + name: 'lin-hytrust-01', + vapp_uuid: '', + operatingSystem: 'windows7Guest', + vnics: [ + { + vnic_id: 0, + network_name: 'A', + is_connected: true + } + ] + }, + { + uuid: '', + name: 'Alert Resource Non Regression VM', + vapp_uuid: '', + operatingSystem: 'windows7Guest', + vnics: [ + { + vnic_id: 0, + network_name: 'A', + is_connected: true + } + ] + }, + { + uuid: '', + name: 'AutomatedSecurityTest1', + vapp_uuid: '', + operatingSystem: 'windows7Guest', + vnics: [ + { + vnic_id: 0, + network_name: 'A', + is_connected: true + } + ] + }, + { + uuid: '', + name: 'CatalogResourceNonRegression1', + vapp_uuid: '', + operatingSystem: 'ubuntu64Guest', + vnics: [ + { + vnic_id: 0, + network_name: 'A', + is_connected: true + } + ] + }, + { + uuid: '', + name: 'lin-hytrust-01', + vapp_uuid: '', + operatingSystem: 'windows7Guest', + vnics: [ + { + vnic_id: 0, + network_name: 'A', + is_connected: true + } + ] + }, + { + uuid: '', + name: 'windows-as-01', + vapp_uuid: '', + operatingSystem: 'ubuntu64Guest', + vnics: [ + { + vnic_id: 0, + network_name: 'A', + is_connected: true + } + ] + }, + { + uuid: '', + name: 'Alert Resource Non Regression VM', + vapp_uuid: '', + operatingSystem: 'windows7Guest', + vnics: [ + { + vnic_id: 0, + network_name: 'A', + is_connected: true + } + ] + }, + { + uuid: '', + name: 'AutomatedSecurityTest1', + vapp_uuid: '', + operatingSystem: 'windows7Guest', + vnics: [ + { + vnic_id: 0, + network_name: 'A', + is_connected: true + } + ] + }, + { + uuid: '', + name: 'CatalogResourceNonRegression1', + vapp_uuid: '', + operatingSystem: 'ubuntu64Guest', + vnics: [ + { + vnic_id: 0, + network_name: 'A', + is_connected: true + } + ] + }, + { + uuid: '', + name: 'lin-hytrust-01', + vapp_uuid: '', + operatingSystem: 'windows7Guest', + vnics: [ + { + vnic_id: 0, + network_name: 'A', + is_connected: true + } + ] + }, + { + uuid: '', + name: 'Alert Resource Non Regression VM', + vapp_uuid: '', + operatingSystem: 'windows7Guest', + vnics: [ + { + vnic_id: 0, + network_name: 'A', + is_connected: true + } + ] + }, + { + uuid: '', + name: 'AutomatedSecurityTest1', + vapp_uuid: '', + operatingSystem: 'windows7Guest', + vnics: [ + { + vnic_id: 0, + network_name: 'A', + is_connected: true + } + ] + }, + { + uuid: '', + name: 'CatalogResourceNonRegression1', + vapp_uuid: '', + operatingSystem: 'windows7Guest', + vnics: [ + { + vnic_id: 0, + network_name: 'A', + is_connected: true + } + ] + }, + { + uuid: '', + name: 'CatalogResourceNonRegression1', + vapp_uuid: '', + operatingSystem: 'ubuntu64Guest', + vnics: [ + { + vnic_id: 0, + network_name: 'A', + is_connected: true + } + ] + }, + { + uuid: '', + name: 'lin-hytrust-01', + vapp_uuid: '', + operatingSystem: 'windows7Guest', + vnics: [ + { + vnic_id: 0, + network_name: 'A', + is_connected: true + } + ] + }, + { + uuid: '', + name: 'windows-as-01', + vapp_uuid: '', + operatingSystem: 'ubuntu64Guest', + vnics: [ + { + vnic_id: 0, + network_name: 'A', + is_connected: true + } + ] + }, + { + uuid: '', + name: 'Alert Resource Non Regression VM', + vapp_uuid: '', + operatingSystem: 'windows7Guest', + vnics: [ + { + vnic_id: 0, + network_name: 'A', + is_connected: true + } + ] + }, + { + uuid: '', + name: 'AutomatedSecurityTest1', + vapp_uuid: '', + operatingSystem: 'windows7Guest', + vnics: [ + { + vnic_id: 0, + network_name: 'A', + is_connected: true + } + ] + }, + { + uuid: '', + name: 'CatalogResourceNonRegression1', + vapp_uuid: '', + operatingSystem: 'ubuntu64Guest', + vnics: [ + { + vnic_id: 0, + network_name: 'A', + is_connected: true + } + ] + }, + { + uuid: '', + name: 'lin-hytrust-01', + vapp_uuid: '', + operatingSystem: 'windows7Guest', + vnics: [ + { + vnic_id: 0, + network_name: 'A', + is_connected: true + } + ] + }, + { + uuid: '', + name: 'Alert Resource Non Regression VM', + vapp_uuid: '', + operatingSystem: 'windows7Guest', + vnics: [ + { + vnic_id: 0, + network_name: 'A', + is_connected: true + } + ] + }, + { + uuid: '', + name: 'AutomatedSecurityTest1', + vapp_uuid: '', + operatingSystem: 'windows7Guest', + vnics: [ + { + vnic_id: 0, + network_name: 'A', + is_connected: true + } + ] + }, + { + uuid: '', + name: 'CatalogResourceNonRegression1', + vapp_uuid: '', + operatingSystem: 'ubuntu64Guest', + vnics: [ + { + vnic_id: 0, + network_name: 'A', + is_connected: true + } + ] + }, + { + uuid: '', + name: 'lin-hytrust-01', + vapp_uuid: '', + operatingSystem: 'windows7Guest', + vnics: [ + { + vnic_id: 0, + network_name: 'A', + is_connected: true + } + ] + }, + { + uuid: '', + name: 'windows-as-01', + vapp_uuid: '', + operatingSystem: 'ubuntu64Guest', + vnics: [ + { + vnic_id: 0, + network_name: 'A', + is_connected: true + } + ] + } + ] + }, + // 12. long vms list and nat-routed vapp network with scrollbar + { + uuid: '', + name: 'Coke RES & BURST', + vapp_networks: [ + { + uuid: '0', + name: '172.16.55.0 Failover Network', + vapp_uuid: '', + fence_mode: 'NAT_ROUTED' + } + ], + vms: [ + { + uuid: '', + name: 'Alert Resource Non Regression VM', + vapp_uuid: '', + operatingSystem: 'windows7Guest', + vnics: [ + { + vnic_id: 0, + network_name: '172.16.55.0 Failover Network', + is_connected: true + } + ] + }, + { + uuid: '', + name: 'AutomatedSecurityTest1', + vapp_uuid: '', + operatingSystem: 'windows7Guest', + vnics: [ + { + vnic_id: 0, + network_name: '172.16.55.0 Failover Network', + is_connected: true + } + ] + }, + { + uuid: '', + name: 'CatalogResourceNonRegression1', + vapp_uuid: '', + operatingSystem: 'ubuntu64Guest', + vnics: [ + { + vnic_id: 0, + network_name: '172.16.55.0 Failover Network', + is_connected: true + } + ] + }, + { + uuid: '', + name: 'lin-hytrust-01', + vapp_uuid: '', + operatingSystem: 'windows7Guest', + vnics: [ + { + vnic_id: 0, + network_name: '172.16.55.0 Failover Network', + is_connected: true + } + ] + }, + { + uuid: '', + name: 'Alert Resource Non Regression VM', + vapp_uuid: '', + operatingSystem: 'windows7Guest', + vnics: [ + { + vnic_id: 0, + network_name: '172.16.55.0 Failover Network', + is_connected: true + } + ] + }, + { + uuid: '', + name: 'AutomatedSecurityTest1', + vapp_uuid: '', + operatingSystem: 'windows7Guest', + vnics: [ + { + vnic_id: 0, + network_name: '172.16.55.0 Failover Network', + is_connected: true + } + ] + }, + { + uuid: '', + name: 'CatalogResourceNonRegression1', + vapp_uuid: '', + operatingSystem: 'ubuntu64Guest', + vnics: [ + { + vnic_id: 0, + network_name: '172.16.55.0 Failover Network', + is_connected: true + } + ] + }, + { + uuid: '', + name: 'lin-hytrust-01', + vapp_uuid: '', + operatingSystem: 'windows7Guest', + vnics: [ + { + vnic_id: 0, + network_name: '172.16.55.0 Failover Network', + is_connected: true + } + ] + }, + { + uuid: '', + name: 'windows-as-01', + vapp_uuid: '', + operatingSystem: 'ubuntu64Guest', + vnics: [ + { + vnic_id: 0, + network_name: '172.16.55.0 Failover Network', + is_connected: true + } + ] + }, + { + uuid: '', + name: 'Alert Resource Non Regression VM', + vapp_uuid: '', + operatingSystem: 'windows7Guest', + vnics: [ + { + vnic_id: 0, + network_name: '172.16.55.0 Failover Network', + is_connected: true + } + ] + }, + { + uuid: '', + name: 'AutomatedSecurityTest1', + vapp_uuid: '', + operatingSystem: 'windows7Guest', + vnics: [ + { + vnic_id: 0, + network_name: '172.16.55.0 Failover Network', + is_connected: true + } + ] + }, + { + uuid: '', + name: 'CatalogResourceNonRegression1', + vapp_uuid: '', + operatingSystem: 'ubuntu64Guest', + vnics: [ + { + vnic_id: 0, + network_name: '172.16.55.0 Failover Network', + is_connected: true + } + ] + }, + { + uuid: '', + name: 'lin-hytrust-01', + vapp_uuid: '', + operatingSystem: 'windows7Guest', + vnics: [ + { + vnic_id: 0, + network_name: '172.16.55.0 Failover Network', + is_connected: true + } + ] + }, + { + uuid: '', + name: 'Alert Resource Non Regression VM', + vapp_uuid: '', + operatingSystem: 'windows7Guest', + vnics: [ + { + vnic_id: 0, + network_name: '172.16.55.0 Failover Network', + is_connected: true + } + ] + }, + { + uuid: '', + name: 'AutomatedSecurityTest1', + vapp_uuid: '', + operatingSystem: 'windows7Guest', + vnics: [ + { + vnic_id: 0, + network_name: '172.16.55.0 Failover Network', + is_connected: true + } + ] + }, + { + uuid: '', + name: 'CatalogResourceNonRegression1', + vapp_uuid: '', + operatingSystem: 'ubuntu64Guest', + vnics: [ + { + vnic_id: 0, + network_name: '172.16.55.0 Failover Network', + is_connected: true + } + ] + }, + { + uuid: '', + name: 'lin-hytrust-01', + vapp_uuid: '', + operatingSystem: 'windows7Guest', + vnics: [ + { + vnic_id: 0, + network_name: '172.16.55.0 Failover Network', + is_connected: true + } + ] + }, + { + uuid: '', + name: 'windows-as-01', + vapp_uuid: '', + operatingSystem: 'ubuntu64Guest', + vnics: [ + { + vnic_id: 0, + network_name: '172.16.55.0 Failover Network', + is_connected: true + } + ] + }, + { + uuid: '', + name: 'Alert Resource Non Regression VM', + vapp_uuid: '', + operatingSystem: 'windows7Guest', + vnics: [ + { + vnic_id: 0, + network_name: '172.16.55.0 Failover Network', + is_connected: true + } + ] + }, + { + uuid: '', + name: 'AutomatedSecurityTest1', + vapp_uuid: '', + operatingSystem: 'windows7Guest', + vnics: [ + { + vnic_id: 0, + network_name: '172.16.55.0 Failover Network', + is_connected: true + } + ] + }, + { + uuid: '', + name: 'CatalogResourceNonRegression1', + vapp_uuid: '', + operatingSystem: 'ubuntu64Guest', + vnics: [ + { + vnic_id: 0, + network_name: '172.16.55.0 Failover Network', + is_connected: true + } + ] + }, + { + uuid: '', + name: 'lin-hytrust-01', + vapp_uuid: '', + operatingSystem: 'windows7Guest', + vnics: [ + { + vnic_id: 0, + network_name: '172.16.55.0 Failover Network', + is_connected: true + } + ] + }, + { + uuid: '', + name: 'Alert Resource Non Regression VM', + vapp_uuid: '', + operatingSystem: 'windows7Guest', + vnics: [ + { + vnic_id: 0, + network_name: '172.16.55.0 Failover Network', + is_connected: true + } + ] + }, + { + uuid: '', + name: 'AutomatedSecurityTest1', + vapp_uuid: '', + operatingSystem: 'windows7Guest', + vnics: [ + { + vnic_id: 0, + network_name: '172.16.55.0 Failover Network', + is_connected: true + } + ] + }, + { + uuid: '', + name: 'CatalogResourceNonRegression1', + vapp_uuid: '', + operatingSystem: 'ubuntu64Guest', + vnics: [ + { + vnic_id: 0, + network_name: '172.16.55.0 Failover Network', + is_connected: true + } + ] + }, + { + uuid: '', + name: 'lin-hytrust-01', + vapp_uuid: '', + operatingSystem: 'windows7Guest', + vnics: [ + { + vnic_id: 0, + network_name: '172.16.55.0 Failover Network', + is_connected: true + } + ] + }, + { + uuid: '', + name: 'windows-as-01', + vapp_uuid: '', + operatingSystem: 'ubuntu64Guest', + vnics: [ + { + vnic_id: 0, + network_name: '172.16.55.0 Failover Network', + is_connected: true + } + ] + } + ] + }, + // 13. multiple vms with max amount of vnics - width edge case + { + uuid: '', + name: 'BillingResourceNonRegressionoooo', + vapp_networks: [ + { + uuid: '0', + name: '0', + vapp_uuid: '', + fence_mode: 'BRIDGED' + }, + { + uuid: '1', + name: '1', + vapp_uuid: '', + fence_mode: 'BRIDGED' + }, + { + uuid: '2', + name: '2', + vapp_uuid: '', + fence_mode: 'BRIDGED' + }, + { + uuid: '3', + name: '3', + vapp_uuid: '', + fence_mode: 'BRIDGED' + }, + { + uuid: '4', + name: '4', + vapp_uuid: '', + fence_mode: 'BRIDGED' + }, + { + uuid: '5', + name: '5', + vapp_uuid: '', + fence_mode: 'BRIDGED' + }, + { + uuid: '6', + name: '6', + vapp_uuid: '', + fence_mode: 'BRIDGED' + }, + { + uuid: '7', + name: '7', + vapp_uuid: '', + fence_mode: 'BRIDGED' + }, + { + uuid: '8', + name: '8', + vapp_uuid: '', + fence_mode: 'BRIDGED' + }, + { + uuid: '9', + name: '9', + vapp_uuid: '', + fence_mode: 'BRIDGED' + }, + { + uuid: '10', + name: '10', + vapp_uuid: '', + fence_mode: 'BRIDGED' + }, + { + uuid: '11', + name: '11', + vapp_uuid: '', + fence_mode: 'BRIDGED' + }, + { + uuid: '12', + name: '12', + vapp_uuid: '', + fence_mode: 'BRIDGED' + }, + { + uuid: '13', + name: '13', + vapp_uuid: '', + fence_mode: 'BRIDGED' + }, + { + uuid: '14', + name: '14', + vapp_uuid: '', + fence_mode: 'BRIDGED' + }, + { + uuid: '15', + name: '15', + vapp_uuid: '', + fence_mode: 'BRIDGED' + }, + { + uuid: '16', + name: '16', + vapp_uuid: '', + fence_mode: 'BRIDGED' + }, + { + uuid: '17', + name: '17', + vapp_uuid: '', + fence_mode: 'BRIDGED' + }, + { + uuid: '18', + name: '18', + vapp_uuid: '', + fence_mode: 'BRIDGED' + }, + { + uuid: '19', + name: '19', + vapp_uuid: '', + fence_mode: 'BRIDGED' + }, + { + uuid: '20', + name: '20', + vapp_uuid: '', + fence_mode: 'BRIDGED' + }, + { + uuid: '21', + name: '21', + vapp_uuid: '', + fence_mode: 'BRIDGED' + }, + { + uuid: '22', + name: '22', + vapp_uuid: '', + fence_mode: 'BRIDGED' + }, + { + uuid: '23', + name: '23', + vapp_uuid: '', + fence_mode: 'BRIDGED' + }, + { + uuid: '24', + name: '24', + vapp_uuid: '', + fence_mode: 'BRIDGED' + }, + { + uuid: '25', + name: '25', + vapp_uuid: '', + fence_mode: 'BRIDGED' + }, + { + uuid: '26', + name: '26', + vapp_uuid: '', + fence_mode: 'BRIDGED' + }, + { + uuid: '27', + name: '27', + vapp_uuid: '', + fence_mode: 'BRIDGED' + }, + { + uuid: '28', + name: '28', + vapp_uuid: '', + fence_mode: 'BRIDGED' + }, + { + uuid: '29', + name: '29', + vapp_uuid: '', + fence_mode: 'BRIDGED' + } + ], + vms: [ + { + uuid: '', + name: 'windows-as-01', + vapp_uuid: '', + operatingSystem: 'ubuntu64Guest', + vnics: [ + { + vnic_id: 0, + network_name: '0', + is_connected: true + }, + { + vnic_id: 1, + network_name: '1', + is_connected: true + }, + { + vnic_id: 2, + network_name: '2', + is_connected: true + }, + { + vnic_id: 3, + network_name: '3', + is_connected: true + }, + { + vnic_id: 4, + network_name: '4', + is_connected: true + }, + { + vnic_id: 5, + network_name: '5', + is_connected: true + }, + { + vnic_id: 6, + network_name: '6', + is_connected: true + }, + { + vnic_id: 7, + network_name: '7', + is_connected: true + }, + { + vnic_id: 8, + network_name: '8', + is_connected: true + }, + { + vnic_id: 9, + network_name: '9', + is_connected: true + } + ] + }, + { + uuid: '', + name: 'AutomatedSecurityTest1', + vapp_uuid: '', + operatingSystem: 'windows7Guest', + vnics: [ + { + vnic_id: 0, + network_name: '10', + is_connected: true + }, + { + vnic_id: 1, + network_name: '11', + is_connected: true + }, + { + vnic_id: 2, + network_name: '12', + is_connected: true + }, + { + vnic_id: 3, + network_name: '13', + is_connected: true + }, + { + vnic_id: 4, + network_name: '14', + is_connected: true + }, + { + vnic_id: 5, + network_name: '15', + is_connected: true + }, + { + vnic_id: 6, + network_name: '16', + is_connected: true + }, + { + vnic_id: 7, + network_name: '17', + is_connected: true + }, + { + vnic_id: 8, + network_name: '18', + is_connected: true + }, + { + vnic_id: 9, + network_name: '19', + is_connected: true + } + ] + }, + { + uuid: '', + name: 'AutomatedSecurityTest2', + vapp_uuid: '', + operatingSystem: 'windows7Guest', + vnics: [ + { + vnic_id: 0, + network_name: '20', + is_connected: true + }, + { + vnic_id: 1, + network_name: '21', + is_connected: true + }, + { + vnic_id: 2, + network_name: '22', + is_connected: true + }, + { + vnic_id: 3, + network_name: '23', + is_connected: true + }, + { + vnic_id: 4, + network_name: '24', + is_connected: true + }, + { + vnic_id: 5, + network_name: '25', + is_connected: true + }, + { + vnic_id: 6, + network_name: '26', + is_connected: true + }, + { + vnic_id: 7, + network_name: '27', + is_connected: true + }, + { + vnic_id: 8, + network_name: '28', + is_connected: true + }, + { + vnic_id: 9, + network_name: '29', + is_connected: true + } + ] + } + ] + }, + // 14. many vms attached to their own network - width edge case + { + uuid: '', + name: 'BillingResourceNonRegressionoooo', + vapp_networks: [ + { + uuid: '0', + name: '0', + vapp_uuid: '', + fence_mode: 'BRIDGED' + }, + { + uuid: '1', + name: '1', + vapp_uuid: '', + fence_mode: 'BRIDGED' + }, + { + uuid: '2', + name: '2', + vapp_uuid: '', + fence_mode: 'BRIDGED' + }, + { + uuid: '3', + name: '3', + vapp_uuid: '', + fence_mode: 'BRIDGED' + }, + { + uuid: '4', + name: '4', + vapp_uuid: '', + fence_mode: 'BRIDGED' + }, + { + uuid: '5', + name: '5', + vapp_uuid: '', + fence_mode: 'BRIDGED' + }, + { + uuid: '6', + name: '6', + vapp_uuid: '', + fence_mode: 'BRIDGED' + }, + { + uuid: '7', + name: '7', + vapp_uuid: '', + fence_mode: 'BRIDGED' + }, + { + uuid: '8', + name: '8', + vapp_uuid: '', + fence_mode: 'BRIDGED' + }, + { + uuid: '9', + name: '9', + vapp_uuid: '', + fence_mode: 'BRIDGED' + }, + { + uuid: '10', + name: '10', + vapp_uuid: '', + fence_mode: 'BRIDGED' + }, + { + uuid: '11', + name: '11', + vapp_uuid: '', + fence_mode: 'BRIDGED' + }, + { + uuid: '12', + name: '12', + vapp_uuid: '', + fence_mode: 'BRIDGED' + } + ], + vms: [ + { + uuid: '', + name: 'Alert Resource Non Regression VM', + vapp_uuid: '', + operatingSystem: 'windows7Guest', + vnics: [ + { + vnic_id: 0, + network_name: '0', + is_connected: true + } + ] + }, + { + uuid: '', + name: 'AutomatedSecurityTest1', + vapp_uuid: '', + operatingSystem: 'windows7Guest', + vnics: [ + { + vnic_id: 0, + network_name: '1', + is_connected: true + } + ] + }, + { + uuid: '', + name: 'CatalogResourceNonRegression1', + vapp_uuid: '', + operatingSystem: 'ubuntu64Guest', + vnics: [ + { + vnic_id: 0, + network_name: '2', + is_connected: true + } + ] + }, + { + uuid: '', + name: 'lin-hytrust-01', + vapp_uuid: '', + operatingSystem: 'windows7Guest', + vnics: [ + { + vnic_id: 0, + network_name: '3', + is_connected: true + } + ] + }, + { + uuid: '', + name: 'Alert Resource Non Regression VM', + vapp_uuid: '', + operatingSystem: 'windows7Guest', + vnics: [ + { + vnic_id: 0, + network_name: '4', + is_connected: true + } + ] + }, + { + uuid: '', + name: 'AutomatedSecurityTest1', + vapp_uuid: '', + operatingSystem: 'windows7Guest', + vnics: [ + { + vnic_id: 0, + network_name: '5', + is_connected: true + } + ] + }, + { + uuid: '', + name: 'CatalogResourceNonRegression1', + vapp_uuid: '', + operatingSystem: 'ubuntu64Guest', + vnics: [ + { + vnic_id: 0, + network_name: '6', + is_connected: true + } + ] + }, + { + uuid: '', + name: 'lin-hytrust-01', + vapp_uuid: '', + operatingSystem: 'windows7Guest', + vnics: [ + { + vnic_id: 0, + network_name: '7', + is_connected: true + } + ] + }, + { + uuid: '', + name: 'windows-as-01', + vapp_uuid: '', + operatingSystem: 'ubuntu64Guest', + vnics: [ + { + vnic_id: 0, + network_name: '8', + is_connected: true + } + ] + }, + { + uuid: '', + name: 'Alert Resource Non Regression VM', + vapp_uuid: '', + operatingSystem: 'windows7Guest', + vnics: [ + { + vnic_id: 0, + network_name: '9', + is_connected: true + } + ] + }, + { + uuid: '', + name: 'AutomatedSecurityTest1', + vapp_uuid: '', + operatingSystem: 'windows7Guest', + vnics: [ + { + vnic_id: 0, + network_name: '10', + is_connected: true + } + ] + }, + { + uuid: '', + name: 'CatalogResourceNonRegression1', + vapp_uuid: '', + operatingSystem: 'ubuntu64Guest', + vnics: [ + { + vnic_id: 0, + network_name: '11', + is_connected: true + } + ] + }, + { + uuid: '', + name: 'lin-hytrust-01', + vapp_uuid: '', + operatingSystem: 'windows7Guest', + vnics: [ + { + vnic_id: 0, + network_name: '12', + is_connected: true + } + ] + } + ] + }, + // 15. variations for vnics on multiple vapp networks + { + uuid: '', + name: 'BC Test vApp', + vapp_networks: [ + { + uuid: '0', + name: 'A', + vapp_uuid: '', + fence_mode: 'BRIDGED' + }, + { + uuid: '1', + name: 'B', + vapp_uuid: '', + fence_mode: 'BRIDGED' + }, + { + uuid: '2', + name: 'C', + vapp_uuid: '', + fence_mode: 'BRIDGED' + }, + { + uuid: '3', + name: 'D', + vapp_uuid: '', + fence_mode: 'BRIDGED' + } + ], + vms: [ + { + uuid: '', + name: 'windows-as-01', + vapp_uuid: '', + operatingSystem: 'ubuntu64Guest', + vnics: [ + { + vnic_id: 0, + network_name: 'A', + is_connected: true + }, + { + vnic_id: 1, + network_name: 'B', + is_connected: true + } + ] + }, + { + uuid: '', + name: 'windows-as-02', + vapp_uuid: '', + operatingSystem: 'ubuntu64Guest', + vnics: [ + { + vnic_id: 0, + network_name: 'C', + is_connected: true + } + ] + }, + { + uuid: '', + name: 'windows-as-03', + vapp_uuid: '', + operatingSystem: 'ubuntu64Guest', + vnics: [ + { + vnic_id: 0, + network_name: 'B', + is_connected: true + }, + { + vnic_id: 1, + network_name: 'D', + is_connected: true + } + ] + } + ] + }, + // 16. variations for unattached vnics + { + uuid: '', + name: 'BC Test vApp', + vapp_networks: [ + { + uuid: '1', + name: 'A', + vapp_uuid: '', + fence_mode: 'BRIDGED' + }, + { + uuid: '2', + name: 'B', + vapp_uuid: '', + fence_mode: 'BRIDGED' + }, + { + uuid: '3', + name: 'C', + vapp_uuid: '', + fence_mode: 'BRIDGED' + } + ], + vms: [ + { + uuid: '', + name: 'windows-as-01', + vapp_uuid: '', + operatingSystem: 'ubuntu64Guest', + vnics: [ + { + vnic_id: 0, + network_name: 'A', + is_connected: true + }, + { + vnic_id: 1, + network_name: '', + is_connected: false + }, + { + vnic_id: 2, + network_name: '', + is_connected: false + }, + { + vnic_id: 3, + network_name: '', + is_connected: false + } + ] + }, + { + uuid: '', + name: 'windows-as-02', + vapp_uuid: '', + operatingSystem: 'ubuntu64Guest', + vnics: [ + { + vnic_id: 0, + network_name: 'B', + is_connected: true + }, + { + vnic_id: 1, + network_name: '', + is_connected: false + } + ] + }, + { + uuid: '', + name: 'windows-as-03', + vapp_uuid: '', + operatingSystem: 'ubuntu64Guest', + vnics: [ + { + vnic_id: 0, + network_name: 'C', + is_connected: true + } + ] + } + ] + }, + // 17. nat-routed vApp network with no attached vms and vnics + { + uuid: '', + name: 'Coke RES & BURST', + vapp_networks: [ + { + uuid: '0', + name: '172.16.55.0 Failover Network 1', + vapp_uuid: '', + fence_mode: 'NAT_ROUTED' + } + ], + vms: [] + }, + // 18. vapp with no vapp networks or vms + { + uuid: '', + name: 'Coke RES & BURST', + vapp_networks: [], + vms: [] + } +]; diff --git a/demo/src/styles.less b/demo/src/styles.less index 73ea989..5be7bb0 100644 --- a/demo/src/styles.less +++ b/demo/src/styles.less @@ -1,4 +1,7 @@ /* You can add global styles to this file, and also import other style files */ + +@import (css) url('https://fonts.googleapis.com/css?family=Roboto:400,500,700&display=swap'); + body { font-family: 'Roboto', sans-serif; } diff --git a/src/.DS_Store b/src/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..1ff6fd93f74e45982e7e599dd6bb762095ea5277 GIT binary patch literal 8196 zcmeHMTWl0n82-Pdz|0idQ{>Wixop}Xs6|>VpmK401FI!%XrV2|uCqI%j7(?i&TN;9 zjTK*vL43js>WdeQ!6y_GpNv5h?U5;1ln!4VEW#9kbp;QHh1;A5 zgfXc|rz1V3bQsEWs_X$#D54YtDxB>p>CQSG=_#dEI6;LIq9-HDP!K+y<5HnJA+2;Q zV+3LZu17%T?gr&Jb04GetMm74$sfCx3o@9>Y7%!Zr9$?;)zpdUU~Davu|JgBn%@&FBGa#{VVcQZq15} z>rZqjWD+mVhD@^i0i$Qg$>qI%=a`)l)jsdLo4b7ldxG~nn9IAxG0)G4nw;t8iY|lG z=DO0^9}4UvfovV{^M#-zbj%1*^zFSi`#BSKCwp_~5;;@vc;Mtcf4^PuSvRJe)#CMY zi1V=@qrcH~g>cy+qB#na>uWnD?lCxRjf~JR- z#x=?0h_rMRY?f9Oux@?Q81Q+o!i*f7HWngNmlabu#npxWwmad+&Wvh`K zoKRa94@QIAwj0TzTz+hfx8m2JDH=|r9lXyGQlQ5|_C(7j>f#bz*cj>elbtZFK&p z)ctXhtejTFHdd9HJd@%3 zn1A*WO_E3~RNkrj290?mG}T>OzfNOvi$vHpZm(xgQxo%)b)(UuFq? z@) { + + beforeAll(() => { + const canvasEl = document.createElement('canvas'); + paper.setup(canvasEl); + }); + + test('basic properties', () => { + const position = new paper.Point(0, 0); + const defaultColor = new paper.Color(VAPP_BACKGROUND_COLOR); + const connector = new ConnectorComponent(); + expect(connector.position.x).toBe(position.x); + expect(connector.position.y).toBe(position.y); + expect((connector.connector.fillColor as paper.Color).equals(defaultColor)).toBe(true); + }); + + test('custom position and fill color', () => { + const position = new paper.Point(-20, 30); + const color = new paper.Color(CANVAS_BACKGROUND_COLOR); + const connector = new ConnectorComponent(position, color); + expect(connector.position.x).toBe(position.x); + expect(connector.position.y).toBe(position.y); + expect((connector.connector.fillColor as paper.Color).equals(color)).toBe(true); + }); + +}); diff --git a/src/components/connector.ts b/src/components/connector.ts new file mode 100644 index 0000000..cd88c1b --- /dev/null +++ b/src/components/connector.ts @@ -0,0 +1,40 @@ +import * as paper from 'paper'; +import { VAPP_BACKGROUND_COLOR } from '../constants/colors'; +import { CONNECTOR_RADIUS } from '../constants/dimensions'; +import { DEFAULT_STROKE_STYLE } from '../constants/styles'; + +/** + * Connector Visual Component. + */ +export class ConnectorComponent extends paper.Group { + + // the stroked circle item + readonly _connector: paper.Path.Circle; + + /** + * Creates a new ConnectorComponent instance. + * + * @param _point the location that the connector should be rendered at + * @param _fillColor the inner fill color that usually matches the background color of the element it is on top of + */ + constructor(private _point: paper.Point = new paper.Point(0, 0), + private _fillColor: paper.Color | string = VAPP_BACKGROUND_COLOR) { + super(); + this.applyMatrix = false; + this.position = _point; + this.pivot = new paper.Point(0, 0); + + this._connector = new paper.Path.Circle( + { + position: new paper.Point(0, 0), + radius: CONNECTOR_RADIUS, + style: DEFAULT_STROKE_STYLE, + fillColor: this._fillColor, + parent: this + }); + } + + get connector(): paper.Path.Circle { + return this._connector; + } +} diff --git a/src/components/entity-label.test.ts b/src/components/entity-label.test.ts new file mode 100644 index 0000000..e27a3d1 --- /dev/null +++ b/src/components/entity-label.test.ts @@ -0,0 +1,36 @@ +import { EntityLabelComponent } from './entity-label'; +import * as paper from 'paper'; +import { LABEL_HORIZONTAL_PADDING } from '../constants/dimensions'; + +describe('entity label component', () => { + + beforeAll(() => { + const canvasEl = document.createElement('canvas'); + paper.setup(canvasEl); + }); + + test('basic properties', () => { + const text = 'foobar'; + const position = new paper.Point(0, 0); + const color = new paper.Color('red'); + const label = new EntityLabelComponent(text, color); + expect(label.position.x).toBe(position.x); + expect(label.position.y).toBe(position.y); + expect(label.icon.fillColor).toBe(color); + }); + + test('fontWeight', () => { + const text = 'foobar'; + const position = new paper.Point(10, 90); + const color = new paper.Color('blue'); + const fontWeight = 'bold'; + const label = new EntityLabelComponent(text, color, position, fontWeight); + expect(label.position.x).toBe(position.x); + expect(label.position.y).toBe(position.y); + expect(label.icon.fillColor).toBe(color); + expect(label.getTextComponent().fontWeight).toBe(fontWeight); + expect(label.bounds.right - LABEL_HORIZONTAL_PADDING) + .toBe(label.localToGlobal(label.getTextComponent().bounds.bottomRight).x); + }); + +}); diff --git a/src/components/entity-label.ts b/src/components/entity-label.ts new file mode 100644 index 0000000..6ad9d1c --- /dev/null +++ b/src/components/entity-label.ts @@ -0,0 +1,58 @@ +import * as paper from 'paper'; +import { LABEL_HORIZONTAL_PADDING } from '../constants/dimensions'; +import { LabelComponent } from './label'; + +const ICON_SIZE = 10; +const ICON_MARGIN = 10; + +/** + * Entity Label Visual Component. + */ +export class EntityLabelComponent extends LabelComponent { + + // the entity icon + readonly _icon: paper.Path.Rectangle; + + /** + * Creates a new EntityLabelComponent instance. + * + * @param _text the text to be displayed on the label + * @param iconColor the icon color specific to the entity type + * @param _point the location that the entity label should be rendered at + * @param fontWeight the font weight of the label. Defaults to 'normal' in parent + */ + constructor(protected _text: string, + private iconColor: paper.Color | string, + protected _point: paper.Point = new paper.Point(0, 0), + private _fontWeight: string | number = '') { + super(_text, _point); + this.applyMatrix = false; + this.pivot = new paper.Point(0, 0); + + this._icon = new paper.Path.Rectangle({ + rectangle: new paper.Rectangle(0, 0, ICON_SIZE, ICON_SIZE), + radius: 2, + pivot: new paper.Point(0, 0), + position: new paper.Point(ICON_MARGIN, ICON_MARGIN), + fillColor: this.iconColor, + parent: this + }); + + // change in font weight will change the background width and possibly trigger clipping that will be + // handled by the parent + if (this._fontWeight) { + super.setFontWeight('bold'); + } + + // reposition and resize other elements to fit the icon + this._label.position.x = this._icon.bounds.right + LABEL_HORIZONTAL_PADDING; + this._background.bounds.width += ICON_SIZE + ICON_MARGIN; + } + + /** + * Gets the icon path item. + */ + get icon(): paper.Path.Rectangle { + return this._icon; + } +} diff --git a/src/components/isolated-network-label.test.ts b/src/components/isolated-network-label.test.ts new file mode 100644 index 0000000..4db5520 --- /dev/null +++ b/src/components/isolated-network-label.test.ts @@ -0,0 +1,29 @@ +import { IsolatedNetworkLabelComponent } from './isolated-network-label'; +import * as paper from 'paper'; + +describe('isolated network label component', () => { + + beforeAll(() => { + const canvasEl = document.createElement('canvas'); + paper.setup(canvasEl); + }); + + test('basic properties', () => { + const text = 'foobar'; + const position = new paper.Point(10, 90); + const label = new IsolatedNetworkLabelComponent(text, position); + expect(label.position.x).toBe(position.x); + expect(label.position.y).toBe(position.y); + expect(label.label.content).toBe(text.toUpperCase()); + }); + + test('maxWidth', () => { + const text = 'really really really really really really really really really really long name'; + const position = new paper.Point(10, 90); + const label = new IsolatedNetworkLabelComponent(text, position); + expect(label.position.x).toBe(position.x); + expect(label.position.y).toBe(position.y); + expect(label.label.bounds.width).toBeLessThan(200); + }); + +}); diff --git a/src/components/isolated-network-label.ts b/src/components/isolated-network-label.ts new file mode 100644 index 0000000..f3a3658 --- /dev/null +++ b/src/components/isolated-network-label.ts @@ -0,0 +1,69 @@ +import * as paper from 'paper'; +import { LIGHT_GREY } from '../constants/colors'; +import { SmallConnectorComponent } from './small-connector'; +import { DEFAULT_MAX_LABEL_WIDTH } from '../constants/dimensions'; + +const LABEL_PADDING_LEFT = 10; + +/** + * Isolated Network Label Visual Component. + */ +export class IsolatedNetworkLabelComponent extends paper.Group { + + readonly _label: paper.PointText; + // icon at the top of the isolated vApp network path + readonly icon: SmallConnectorComponent; + + /** + * Creates a new VappEdgeLabelComponent instance. + * + * @param network the network name + * @param _point the location that the vApp edge label should be rendered at + * @param maxWidth the maximum width of the label (text will be truncated with an ellipsis if the max width is + * exceeded) + */ + constructor(private network: string, + private _point: paper.Point = new paper.Point(0, 0), + private maxWidth: number = DEFAULT_MAX_LABEL_WIDTH) { + super(); + this.applyMatrix = false; + this.pivot = new paper.Point(0, 0); + this.position = _point; + + this.icon = new SmallConnectorComponent(); + this.addChild(this.icon); + + this._label = new paper.PointText({ + content: this.network.toUpperCase(), + fillColor: LIGHT_GREY, + fontWeight: 'bold', + fontSize: 10, + parent: this + }); + // position the label to the right of the icon + this._label.bounds.leftCenter = this.icon.bounds.rightCenter.add(new paper.Point(LABEL_PADDING_LEFT, 0)); + this.clip(); + } + + /** + * Gets the label. + */ + get label(): paper.PointText { + return this._label; + } + + // TODO: maxWidth and clip is reused from the generic label component. is there a better way to share these? + /** + * Clips the text and inserts an ellipsis to ensure that the max width is not exceeded. + */ + private clip() { + let clipped = false; + while (this._label.bounds.width > this.maxWidth) { + clipped = true; + this._label.content = this._label.content.substring(0, this._label.content.length - 1); + } + if (clipped) { + this._label.content = this._label.content.substring(0, this._label.content.length - 3) + '...'; + } + } +} diff --git a/src/components/label.ts b/src/components/label.ts index b42a3a5..bece167 100644 --- a/src/components/label.ts +++ b/src/components/label.ts @@ -1,7 +1,6 @@ import * as paper from 'paper'; -import { LABEL_HORIZONTAL_PADDING } from '../constants/dimensions'; +import { LABEL_HORIZONTAL_PADDING, LABEL_HEIGHT, DEFAULT_MAX_LABEL_WIDTH } from '../constants/dimensions'; -const HEIGHT = 30; const TEXT_COLOR = '#FFFFFF'; const TEXT_HOVER_COLOR = '#FFFFFF'; const ACTIVE_TEXT_COLOR = '#252A3A'; @@ -11,10 +10,10 @@ const HOVER_BACKGROUND_COLOR = '#242A3B'; export const VERTICAL_PADDING_TOP = 6; export const FONT_SIZE = 13; const LINE_HEIGHT = 15; -const DEFAULT_MAX_LABEL_WIDTH = 200; +const FONT_FAMILY = 'Roboto'; /** - * LabelComponent Visual Component. + * Label Visual Component. */ export class LabelComponent extends paper.Group { @@ -31,10 +30,12 @@ export class LabelComponent extends paper.Group { * @param maxWidth the maximum width of the label (text will be truncated with an ellipsis if the max width is * exceeded) */ - constructor(protected _text: string, protected _point: paper.Point = new paper.Point(0, 0), - protected maxWidth = DEFAULT_MAX_LABEL_WIDTH) { + constructor(protected _text: string, + protected _point: paper.Point = new paper.Point(0, 0), + protected _maxWidth = DEFAULT_MAX_LABEL_WIDTH) { super(); this.applyMatrix = false; + this.pivot = new paper.Point(0, 0); this.position = _point; this._label = new paper.PointText(new paper.Point(LABEL_HORIZONTAL_PADDING, VERTICAL_PADDING_TOP + FONT_SIZE)); @@ -44,10 +45,11 @@ export class LabelComponent extends paper.Group { this._label.fontSize = FONT_SIZE; this._label.leading = LINE_HEIGHT; this._label.pivot = new paper.Point(0, 0); + this._label.fontFamily = FONT_FAMILY; this.clip(); this._background = new paper.Path.Rectangle({ rectangle: new paper.Rectangle(0, 0, - this._label.bounds.width + (LABEL_HORIZONTAL_PADDING * 2), HEIGHT), + this._label.bounds.width + (LABEL_HORIZONTAL_PADDING * 2), LABEL_HEIGHT), radius: 3 }); this._background.fillColor = BACKGROUND_COLOR; @@ -70,6 +72,16 @@ export class LabelComponent extends paper.Group { return this._background; } + /** + * Sets the font weight for the label. Updates background width to fit font change and checks for clipping. + * @param weight String or number value for font weight. + */ + setFontWeight(weight: string | number) { + this._label.fontWeight = weight; + this.clip(); + this._background.bounds.width = this._label.bounds.width + (LABEL_HORIZONTAL_PADDING * 2); + } + /** * Sets the label to its visual hover state. */ @@ -87,7 +99,7 @@ export class LabelComponent extends paper.Group { } /** - * Sets the label to its visual active state; + * Sets the label to its visual active state. */ setActive() { this._label.fillColor = ACTIVE_TEXT_COLOR; @@ -99,7 +111,7 @@ export class LabelComponent extends paper.Group { */ private clip() { let clipped = false; - while (this._label.bounds.width > this.maxWidth) { + while (this._label.bounds.width > this._maxWidth) { clipped = true; this._label.content = this._label.content.substring(0, this._label.content.length - 1); } diff --git a/src/components/margin.test.ts b/src/components/margin.test.ts new file mode 100644 index 0000000..33b17bc --- /dev/null +++ b/src/components/margin.test.ts @@ -0,0 +1,216 @@ +import { MarginComponent } from './margin'; +import * as paper from 'paper'; + +describe('margin component', () => { + + beforeAll(() => { + const canvasEl = document.createElement('canvas'); + paper.setup(canvasEl); + }); + + function createContent() { + return new paper.Path.Rectangle({ + pivot: new paper.Point(0, 0), + position: new paper.Point(0, 0), + size: new paper.Size(100, 100) + }); + } + + test('basic default properties', () => { + const content = createContent(); + const margin = new MarginComponent(content); + expect(margin.bounds.size.height).toBe(content.bounds.height); + expect(margin.bounds.size.width).toBe(content.bounds.width); + expect(margin.bounds.top).toBe(content.bounds.top); + expect(margin.bounds.right).toBe(content.bounds.right); + expect(margin.bounds.bottom).toBe(content.bounds.bottom); + expect(margin.bounds.left).toBe(content.bounds.left); + expect(margin.position.x).toBe(content.position.x); + expect(margin.position.y).toBe(content.position.y); + }); + + test('initialized with one value', () => { + const content = createContent(); + const marginValue = 20; + const margin = new MarginComponent(content, marginValue); + expect(margin.bounds.size.height).toBe(content.bounds.height + marginValue * 2); + expect(margin.bounds.size.width).toBe(content.bounds.width + marginValue * 2); + expect(margin.bounds.top).toBe(content.bounds.top - marginValue); + expect(margin.bounds.right).toBe(content.bounds.right + marginValue); + expect(margin.bounds.bottom).toBe(content.bounds.bottom + marginValue); + expect(margin.bounds.left).toBe(content.bounds.left - marginValue); + expect(margin.position.x).toBe(content.position.x - marginValue); + expect(margin.position.y).toBe(content.position.y - marginValue); + }); + + test('initialized with two values', () => { + const content = createContent(); + const marginValue = { + vertical: 10, + horizontal: 20 + }; + const margin = new MarginComponent(content, marginValue.vertical, marginValue.horizontal); + expect(margin.bounds.size.height).toBe(content.bounds.height + marginValue.vertical * 2); + expect(margin.bounds.size.width).toBe(content.bounds.width + marginValue.horizontal * 2); + expect(margin.bounds.top).toBe(content.bounds.top - marginValue.vertical); + expect(margin.bounds.right).toBe(content.bounds.right + marginValue.horizontal); + expect(margin.bounds.bottom).toBe(content.bounds.bottom + marginValue.vertical); + expect(margin.bounds.left).toBe(content.bounds.left - marginValue.horizontal); + expect(margin.position.x).toBe(content.position.x - marginValue.horizontal); + expect(margin.position.y).toBe(content.position.y - marginValue.vertical); + }); + + test('initialized with three values', () => { + const content = createContent(); + const marginValue = { + top: 10, + horizontal: 20, + bottom: 15 + }; + const margin = new MarginComponent(content, marginValue.top, marginValue.horizontal, marginValue.bottom); + expect(margin.bounds.size.height).toBe(content.bounds.height + marginValue.top + marginValue.bottom); + expect(margin.bounds.size.width).toBe(content.bounds.width + marginValue.horizontal * 2); + expect(margin.bounds.top).toBe(content.bounds.top - marginValue.top); + expect(margin.bounds.right).toBe(content.bounds.right + marginValue.horizontal); + expect(margin.bounds.bottom).toBe(content.bounds.bottom + marginValue.bottom); + expect(margin.bounds.left).toBe(content.bounds.left - marginValue.horizontal); + expect(margin.position.x).toBe(content.position.x - marginValue.horizontal); + expect(margin.position.y).toBe(content.position.y - marginValue.top); + }); + + test('initialized with four values', () => { + const content = createContent(); + const marginValue = { + top: 10, + right: 20, + bottom: 15, + left: 30 + }; + const margin = new MarginComponent( + content, marginValue.top, marginValue.right, marginValue.bottom, marginValue.left); + expect(margin.bounds.size.height).toBe(content.bounds.height + marginValue.top + marginValue.bottom); + expect(margin.bounds.size.width).toBe(content.bounds.width + marginValue.right + marginValue.left); + expect(margin.bounds.top).toBe(content.bounds.top - marginValue.top); + expect(margin.bounds.right).toBe(content.bounds.right + marginValue.right); + expect(margin.bounds.bottom).toBe(content.bounds.bottom + marginValue.bottom); + expect(margin.bounds.left).toBe(content.bounds.left - marginValue.left); + expect(margin.position.x).toBe(content.position.x - marginValue.left); + expect(margin.position.y).toBe(content.position.y - marginValue.top); + }); + + test('setValues', () => { + const content = createContent(); + const originalMarginValue = { + top: 10, + right: 20, + bottom: 15, + left: 30 + }; + const marginValue = { + top: 10, + right: 20, + bottom: 15, + left: 30 + }; + const margin = new MarginComponent(content, + originalMarginValue.top, originalMarginValue.right, originalMarginValue.bottom, originalMarginValue.left); + margin.setValues(marginValue.top, marginValue.right, marginValue.bottom, marginValue.left); + expect(margin.bounds.size.height).toBe(content.bounds.height + marginValue.top + marginValue.bottom); + expect(margin.bounds.size.width).toBe(content.bounds.width + marginValue.right + marginValue.left); + expect(margin.bounds.top).toBe(content.bounds.top - marginValue.top); + expect(margin.bounds.right).toBe(content.bounds.right + marginValue.right); + expect(margin.bounds.bottom).toBe(content.bounds.bottom + marginValue.bottom); + expect(margin.bounds.left).toBe(content.bounds.left - marginValue.left); + expect(margin.position.x).toBe(content.position.x - marginValue.left); + expect(margin.position.y).toBe(content.position.y - marginValue.top); + }); + + test('set top', () => { + const marginValue = { + top: 10, + right: 20, + bottom: 15, + left: 30 + }; + const newValue = 3; + const margin = new MarginComponent( + createContent(), marginValue.top, marginValue.right, marginValue.bottom, marginValue.left); + margin.top = newValue; + const content = createContent(); + expect(margin.bounds.size.height).toBe(content.bounds.height + newValue + marginValue.bottom); + expect(margin.bounds.size.width).toBe(content.bounds.width + marginValue.right + marginValue.left); + expect(margin.bounds.top).toBe(content.bounds.top - newValue); + expect(margin.bounds.right).toBe(content.bounds.right + marginValue.right); + expect(margin.bounds.bottom).toBe(content.bounds.bottom + marginValue.bottom); + expect(margin.bounds.left).toBe(content.bounds.left - marginValue.left); + expect(margin.position.x).toBe(content.position.x - marginValue.left); + expect(margin.position.y).toBe(content.position.y - newValue); + }); + + test('set right', () => { + const marginValue = { + top: 10, + right: 20, + bottom: 15, + left: 30 + }; + const newValue = 8; + const margin = new MarginComponent( + createContent(), marginValue.top, marginValue.right, marginValue.bottom, marginValue.left); + margin.right = newValue; + const content = createContent(); + expect(margin.bounds.size.height).toBe(content.bounds.height + marginValue.top + marginValue.bottom); + expect(margin.bounds.size.width).toBe(content.bounds.width + newValue + marginValue.left); + expect(margin.bounds.top).toBe(content.bounds.top - marginValue.top); + expect(margin.bounds.right).toBe(content.bounds.right + newValue); + expect(margin.bounds.bottom).toBe(content.bounds.bottom + marginValue.bottom); + expect(margin.bounds.left).toBe(content.bounds.left - marginValue.left); + expect(margin.position.x).toBe(content.position.x - marginValue.left); + expect(margin.position.y).toBe(content.position.y - marginValue.top); + }); + + test('set bottom', () => { + const marginValue = { + top: 10, + right: 20, + bottom: 15, + left: 30 + }; + const newValue = 8; + const margin = new MarginComponent( + createContent(), marginValue.top, marginValue.right, marginValue.bottom, marginValue.left); + margin.bottom = newValue; + const content = createContent(); + expect(margin.bounds.size.height).toBe(content.bounds.height + marginValue.top + newValue); + expect(margin.bounds.size.width).toBe(content.bounds.width + marginValue.right + marginValue.left); + expect(margin.bounds.top).toBe(content.bounds.top - marginValue.top); + expect(margin.bounds.right).toBe(content.bounds.right + marginValue.right); + expect(margin.bounds.bottom).toBe(content.bounds.bottom + newValue); + expect(margin.bounds.left).toBe(content.bounds.left - marginValue.left); + expect(margin.position.x).toBe(content.position.x - marginValue.left); + expect(margin.position.y).toBe(content.position.y - marginValue.top); + }); + + test('set left', () => { + const marginValue = { + top: 10, + right: 20, + bottom: 15, + left: 30 + }; + const newValue = 8; + const margin = new MarginComponent( + createContent(), marginValue.top, marginValue.right, marginValue.bottom, marginValue.left); + margin.left = newValue; + const content = createContent(); + expect(margin.bounds.size.height).toBe(content.bounds.height + marginValue.top + marginValue.bottom); + expect(margin.bounds.size.width).toBe(content.bounds.width + marginValue.right + newValue); + expect(margin.bounds.top).toBe(content.bounds.top - marginValue.top); + expect(margin.bounds.right).toBe(content.bounds.right + marginValue.right); + expect(margin.bounds.bottom).toBe(content.bounds.bottom + marginValue.bottom); + expect(margin.bounds.left).toBe(content.bounds.left - newValue); + expect(margin.position.x).toBe(content.position.x - newValue); + expect(margin.position.y).toBe(content.position.y - marginValue.top); + }); + +}); diff --git a/src/components/margin.ts b/src/components/margin.ts new file mode 100644 index 0000000..b06e6bd --- /dev/null +++ b/src/components/margin.ts @@ -0,0 +1,151 @@ +import * as paper from 'paper'; + +const DEFAULT_MARGIN = 0; + +/** + * Interface for margin values. + */ +export interface MarginValues { + top: number; + right: number; + bottom: number; + left: number; +} + +/** + * Margin Component. + */ +export class MarginComponent extends paper.Group { + private marginValues: MarginValues; + private margin: paper.Path.Rectangle; + + /** + * Creates a new margin instance. + * + * @param content The content that the margin surrounds. + * @param marginValues Values for the margin sides. Can have a series of 0 to 4 that are comma separated in CSS + * shorthand order. Default is 0 for all margins. + */ + constructor(private content: paper.Item | paper.Group, ...marginValues: number[]) { + super(); + this.applyMatrix = false; + this.pivot = new paper.Point(0, 0); + + this.marginValues = this.assignMarginValues(marginValues); + this.createAndPositionMargin(); + } + + /** + * Sets new margin values. + * @param values Series of number values. Can have 0 to 4 that are comma separated in CSS shorthand order. Default + * is 0 for all margins. + */ + setValues(...values: number[]): void { + const top = this.marginValues.top; + const left = this.marginValues.left; + this.marginValues = this.assignMarginValues(values); + this.rebuildMargin(); + this.content.position.y += this.marginValues.top - top; + this.content.position.x += this.marginValues.left - left; + } + + /** + * Sets top margin value. + * @param newValue New value for top margin. + */ + set top(newValue: number) { + const delta = newValue - this.marginValues.top; + this.marginValues.top = newValue; + this.rebuildMargin(); + this.content.position.y += delta; + } + + /** + * Sets right margin value. + * @param newValue New value for right margin. + */ + set right(newValue: number) { + this.marginValues.right = newValue; + this.rebuildMargin(); + } + + /** + * Sets bottom margin value. + * @param newValue New value for bottom margin. + */ + set bottom(newValue: number) { + this.marginValues.bottom = newValue; + this.rebuildMargin(); + } + + /** + * Sets left margin value. + * @param newValue New value for left margin. + */ + set left(newValue: number) { + const delta = newValue - this.marginValues.left; + this.marginValues.left = newValue; + this.rebuildMargin(); + this.content.position.x += delta; + } + + /** + * Assigns margin values based on CSS shorthand style. + * @param values + */ + private assignMarginValues(values: number[]): MarginValues { + let top; + let right; + let bottom; + let left; + switch (values.length) { + case 4: + [top, right, bottom, left] = values; + break; + case 3: + [top, right, bottom] = values; + left = right; + break; + case 2: + [top, right] = values; + bottom = top; + left = right; + break; + case 1: + top = right = bottom = left = values[0]; + break; + default: + top = right = bottom = left = DEFAULT_MARGIN; + } + + return { + top: top, + right: right, + bottom: bottom, + left: left + }; + } + + /** + * Creates and positions margin. + */ + private createAndPositionMargin(): void { + this.margin = new paper.Path.Rectangle({ + pivot: new paper.Point(0, 0), + position: this.content.bounds.topLeft, + size: this.content.bounds.size.add(new paper.Size( + this.marginValues.left + this.marginValues.right, + this.marginValues.top + this.marginValues.bottom)), + parent: this + }); + this.position = new paper.Point(-this.marginValues.left, -this.marginValues.top); + } + + /** + * Rebuilds margin. + */ + private rebuildMargin(): void { + this.margin.remove(); + this.createAndPositionMargin(); + } +} diff --git a/src/components/scrollbar.test.ts b/src/components/scrollbar.test.ts index 81dc5c9..1e4e311 100644 --- a/src/components/scrollbar.test.ts +++ b/src/components/scrollbar.test.ts @@ -3,7 +3,7 @@ import { ScrollbarComponent } from './scrollbar'; describe('scrollbar component', () => { - beforeEach(() => { + beforeAll(() => { const canvasEl = document.createElement('canvas'); paper.setup(canvasEl); }); diff --git a/src/components/scrollbar.ts b/src/components/scrollbar.ts index bbc3c0b..0a665c8 100644 --- a/src/components/scrollbar.ts +++ b/src/components/scrollbar.ts @@ -55,6 +55,8 @@ class CustomEffects { * Scrollbar Visual Component. */ export class ScrollbarComponent extends paper.Group { + + static anyIsDragging: boolean = false; protected scrollbar: paper.Path; protected track: paper.Path; protected scrollAmount: number; @@ -67,10 +69,14 @@ export class ScrollbarComponent extends paper.Group { private dimension: (keyof paper.Rectangle); // content's original position. used to constrain content position while scrolling private contentInitialPosition: number; - // scrollbar is rendered and enabled when it's needed - private isEnabled: boolean = true; + // scrollbar is visible and interactive when container is hovered + private isActivatable: boolean = false; + // scrollbar is enabled when content does not fit container + private _isEnabled: boolean = true; private containerSize: number; + // make a bigger area to make the track easier to interact with private extendedTrackArea: paper.Path.Rectangle; + // make a bigger area to make the scrollbar easier to interact with private extendedScrollbarArea: paper.Path.Rectangle; private arrowKeyDown: paper.Tool; private scrollbarDrag: paper.Tool; @@ -151,13 +157,44 @@ export class ScrollbarComponent extends paper.Group { this.onClick = this.trackClick; this.scrollbarDrag = this.scrollbarDragTool(); this.arrowKeyDown = this.arrowKeyDownTool(); + + // not visible until container is hovered and no other scrollbars are currently in dragging state + this.visible = false; + } + + /** + * Gets isEnabled state. The scrollbar is enabled when content does not fit the container. + */ + get isEnabled(): boolean { + return this._isEnabled; + } + + /** + * Handler for container mouse enter event. + */ + containerMouseEnter = (): void => { + if (!ScrollbarComponent.anyIsDragging) { + this.isActivatable = true; + this.visible = true; + this.activateDefaultTool(); + } + } + + /** + * Handler for container mouse leave event. + */ + containerMouseLeave = (): void => { + if (!ScrollbarComponent.anyIsDragging) { + this.isActivatable = false; + this.visible = false; + } } /** * Activate the default tool. Used when the scrollable container is hovered or active. */ activateDefaultTool(): void { - if (this.isEnabled) { + if (this.isActivatable) { this.arrowKeyDown.activate(); } } @@ -176,7 +213,7 @@ export class ScrollbarComponent extends paper.Group { * }; */ onScroll(event: WheelEvent): void { - if (this.isEnabled) { + if (this.isActivatable) { const validScrollDirection = this.isHorizontal ? event.deltaX !== 0 : event.deltaY !== 0; if (!validScrollDirection) { return; @@ -290,15 +327,16 @@ export class ScrollbarComponent extends paper.Group { * Enable scrollbar visibility and interactivity. */ private enable(): void { - this.isEnabled = true; + this._isEnabled = true; this.addChildren([this.track, this.scrollbar, this.extendedTrackArea]); } /** - * Disable scrollbar visibility and interactivity. + * Disable scrollbar component elements and interactivity. */ private disable(): void { - this.isEnabled = false; + this.isActivatable = false; + this._isEnabled = false; this.removeChildren(); } @@ -385,7 +423,9 @@ export class ScrollbarComponent extends paper.Group { * The proportionate length for the scrollbar. Based on viewable content size divided by the full content size. */ private getProportionalLength(): number { - return (this.containerSize / this.contentSizeWithOffsets()) * this.scrollTrackLength; + const fullSize = this.contentSizeWithOffsets() + (this.scrollable.contentOffsetEnd || 0) + - (this.scrollable.contentOffsetStart || 0); + return this.containerSize / fullSize * this.scrollTrackLength; } /** @@ -523,21 +563,32 @@ export class ScrollbarComponent extends paper.Group { let offsetPoint: paper.Point; tool.onMouseDown = (event) => { this.dragging = true; + ScrollbarComponent.anyIsDragging = true; this.project.view.element.style.cursor = 'grabbing'; offsetPoint = new paper.Point(event.downPoint.subtract(this.scrollbar.position)); }; - tool.onMouseUp = () => { + tool.onMouseUp = (event: paper.ToolEvent) => { this.dragging = false; + ScrollbarComponent.anyIsDragging = false; this.project.view.element.style.cursor = this.hovering ? 'pointer' : 'default'; + // set visibility based on if content is holding the current point + if (!this.scrollable.containerBounds.contains(event.point)) { + this.visible = false; + // TODO: less kludgey way of activating default tool. it's the last one created in the demo, but is a very + // temp fix. more tools can be added in the future, so this index won't always be correct + paper.tools[paper.tools.length - 1].activate(); + this.setNormal(); + } }; tool.onMouseDrag = (event: paper.ToolEvent) => { this.setActive(); + this.visible = true; return this.isHorizontal ? this.changeScrollAndContentPosition(event.point.x - offsetPoint.x) : this.changeScrollAndContentPosition(event.point.y - offsetPoint.y); }; - // arrowKeyDown tool is inactive while hovering on the scrollbar and scrollbarDrag is active. this handles keyDown - // events + // arrowKeyDown tool is inactive while hovering on the scrollbar and scrollbarDrag tool is active. this handles + // keyDown events tool.onKeyDown = (event) => { this.moveByKeyDown(event); this.activateDefaultTool(); diff --git a/src/components/small-connector.test.ts b/src/components/small-connector.test.ts new file mode 100644 index 0000000..f183d24 --- /dev/null +++ b/src/components/small-connector.test.ts @@ -0,0 +1,25 @@ +import { SmallConnectorComponent } from './small-connector'; +import * as paper from 'paper'; + +describe('small connector component', () => { + + beforeAll(() => { + const canvasEl = document.createElement('canvas'); + paper.setup(canvasEl); + }); + + test('basic properties', () => { + const position = new paper.Point(0, 0); + const connector = new SmallConnectorComponent(); + expect(connector.position.x).toBe(position.x); + expect(connector.position.y).toBe(position.y); + }); + + test('custom position', () => { + const position = new paper.Point(42, -10); + const connector = new SmallConnectorComponent(position); + expect(connector.position.x).toBe(position.x); + expect(connector.position.y).toBe(position.y); + }); + +}); diff --git a/src/components/small-connector.ts b/src/components/small-connector.ts new file mode 100644 index 0000000..62168b0 --- /dev/null +++ b/src/components/small-connector.ts @@ -0,0 +1,35 @@ +import * as paper from 'paper'; +import { LIGHT_GREY } from '../constants/colors'; +import { SMALL_CONNECTOR_SIZE } from '../constants/dimensions'; + +/** + * Small Connector Visual Component. + */ +export class SmallConnectorComponent extends paper.Group { + + // the small filled circle + readonly _connector: paper.Path.Circle; + + /** + * Creates a new VappEdgeLabelComponent instance. + * + * @param _point the location that the vapp edge label should be rendered at + */ + constructor(private _point: paper.Point = new paper.Point(0, 0)) { + super(); + this.applyMatrix = false; + this.position = _point; + this.pivot = new paper.Point(0, 0); + + this._connector = new paper.Path.Circle({ + position: new paper.Point(0, 0), + radius: SMALL_CONNECTOR_SIZE / 2, + fillColor: LIGHT_GREY, + parent: this + }); + } + + get connector(): paper.Path.Circle { + return this._connector; + } +} diff --git a/src/components/vapp-edge-label.test.ts b/src/components/vapp-edge-label.test.ts new file mode 100644 index 0000000..c3dae2c --- /dev/null +++ b/src/components/vapp-edge-label.test.ts @@ -0,0 +1,20 @@ +import { VappEdgeLabelComponent } from './vapp-edge-label'; +import * as paper from 'paper'; + +describe('vapp edge label component', () => { + + beforeAll(() => { + const canvasEl = document.createElement('canvas'); + paper.setup(canvasEl); + }); + + test('basic properties', () => { + const text = 'test name'; + const position = new paper.Point(35, 80); + const label = new VappEdgeLabelComponent(text, position); + expect(label.text).toBe(text); + expect(label.position.x).toBe(position.x); + expect(label.position.y).toBe(position.y); + }); + +}); diff --git a/src/components/vapp-edge-label.ts b/src/components/vapp-edge-label.ts new file mode 100644 index 0000000..0d3071f --- /dev/null +++ b/src/components/vapp-edge-label.ts @@ -0,0 +1,58 @@ +import * as paper from 'paper'; +import { EntityLabelComponent } from './entity-label'; +// import { VappNetworkData } from './vapp-network'; +import { VAPP_BACKGROUND_COLOR, LIGHT_GREY } from '../constants/colors'; +import { CONNECTOR_RADIUS, DEFAULT_STROKE_WIDTH, LABEL_HEIGHT, CONNECTOR_MARGIN } from '../constants/dimensions'; +import { DEFAULT_STROKE_STYLE } from '../constants/styles'; + +const ICON_COLOR = '#EBB86C'; + +/** + * Vapp Nat-Routed Edge Label Visual Component. + */ +export class VappEdgeLabelComponent extends paper.Group { + + readonly _label: EntityLabelComponent; + + /** + * Creates a new VappEdgeLabelComponent instance. + * + * @param vappEdge the vappEdge data + * @param _point the location that the component will be rendered at. + */ + constructor(private _text: string, + private _point: paper.Point = new paper.Point(0, 0)) { + super(); + this.applyMatrix = false; + this.pivot = new paper.Point(0, 0); + this.position = _point; + + this._label = new EntityLabelComponent(this._text, ICON_COLOR); + this._label.getBackgroundComponent().style = { + ...DEFAULT_STROKE_STYLE, + fillColor: VAPP_BACKGROUND_COLOR + }; + this.addChild(this._label); + + // create the top and bottom arcs that connects the label to the network path + const connectorTopArc = new paper.Path.Arc({ + from: new paper.Point(-CONNECTOR_RADIUS, 0), + through: new paper.Point(0, -CONNECTOR_RADIUS), + to: new paper.Point(CONNECTOR_RADIUS, 0), + pivot: new paper.Point(0, 0), + position: new paper.Point(CONNECTOR_MARGIN + CONNECTOR_RADIUS - DEFAULT_STROKE_WIDTH / 2, 0), + fillColor: LIGHT_GREY, + parent: this + }); + const connectorBottomArc = connectorTopArc.clone(); + connectorBottomArc.rotate(180); + connectorBottomArc.position.y = LABEL_HEIGHT; + } + + /** + * Gets the label text. + */ + get text(): string { + return this._text; + } +} diff --git a/src/components/vapp-network-list.test.ts b/src/components/vapp-network-list.test.ts new file mode 100644 index 0000000..74c342a --- /dev/null +++ b/src/components/vapp-network-list.test.ts @@ -0,0 +1,61 @@ +import { VappNetworkListComponent } from './vapp-network-list'; +import { VappNetworkData } from './vapp-network'; +import * as paper from 'paper'; + +describe('vapp component', () => { + + beforeAll(() => { + const canvasEl = document.createElement('canvas'); + paper.setup(canvasEl); + }); + + test('basic properties', () => { + const vappNetworksData: VappNetworkData[] = [ + { + uuid: '0', + name: '172.16.55.0 Failover Network 1', + vapp_uuid: '', + fence_mode: 'BRIDGED' + }, + { + uuid: '1', + name: '172.16.55.0 Failover Network 2', + vapp_uuid: '', + fence_mode: 'NAT_ROUTED' + }, + { + uuid: '2', + name: '172.16.55.0 Failover Network 3', + vapp_uuid: '', + fence_mode: 'ISOLATED' + } + ]; + const position = new paper.Point(0, 0); + const networks = new VappNetworkListComponent(vappNetworksData); + vappNetworksData.forEach((data, i) => { + expect(networks.data[i].uuid).toBe(data.uuid); + expect(networks.data[i].name).toBe(data.name); + expect(networks.data[i].vapp_uuid).toBe(data.vapp_uuid); + expect(networks.data[i].fence_mode).toBe(data.fence_mode); + }); + expect(networks.position.x).toBe(position.x); + expect(networks.position.y).toBe(position.y); + expect(networks.children.length).toBe(3); // 3 paths created from data + }); + + test('with no networks', () => { + const vappNetworksData: VappNetworkData[] = []; + const position = new paper.Point(30, 40); + const networks = new VappNetworkListComponent(vappNetworksData, position); + vappNetworksData.forEach((data, i) => { + expect(networks.data[i].uuid).toBe(data.uuid); + expect(networks.data[i].name).toBe(data.name); + expect(networks.data[i].vapp_uuid).toBe(data.vapp_uuid); + expect(networks.data[i].fence_mode).toBe(data.fence_mode); + }); + expect(networks.position.x).toBe(position.x); + expect(networks.position.y).toBe(position.y); + expect(networks.children.length).toBe(0); // no paths + }); + +}); diff --git a/src/components/vapp-network-list.ts b/src/components/vapp-network-list.ts new file mode 100644 index 0000000..941bb03 --- /dev/null +++ b/src/components/vapp-network-list.ts @@ -0,0 +1,108 @@ +import * as paper from 'paper'; +import { VappNetworkData, VappNetworkComponent } from './vapp-network'; +import { LowestVnicPointByNetworkName } from './vm-and-vnic-list'; +import { DEFAULT_STROKE_STYLE } from '../constants/styles'; +import { VAPP_NETWORK_RIGHT_MARGIN } from '../constants/dimensions'; + +/** + * Interface for network positions by network name. + */ +export interface VappNetworkPositionsByName { + [name: string]: paper.Point; +} + +/** + * Virtual Application Network Visual Component. + */ +export class VappNetworkListComponent extends paper.Group { + // store network position by network name for matching vnic horizontal positioning + private _networkPositionsByName: VappNetworkPositionsByName = {}; + // list of network paths for easier iteration + private networkPathList: VappNetworkComponent[] = []; + // number of isolated networks used to stagger the height of isolated network labels + private isolatedNetworkCount = 0; + + /** + * Creates a new VappNetworkListComponent instance. + * + * @param vappNetworks the vapp networks data + * @param _point the location that the vapp network list should be rendered at + */ + constructor(private _vappNetworks: VappNetworkData[], + private _point: paper.Point = new paper.Point(0, 0)) { + super(); + this.applyMatrix = false; + this.pivot = new paper.Point(0, 0); + this.position = this._point; + + // create and start the network path at the vapp's top boundary + // number of edge networks used to stagger the height of edge network labels + let edgeNetworkCount = 0; + // TODO: pass in the y coordinate of matching org-vdc network when they are eventually included + this._vappNetworks.forEach((networkData, index) => { + const network = new VappNetworkComponent( + networkData, + new paper.Point(VAPP_NETWORK_RIGHT_MARGIN * index, 0), + edgeNetworkCount + ); + this._networkPositionsByName[networkData.name] = network.position; + this.networkPathList.push(network); + // insert at the bottom below any existing vapp network edge labels that were added in the VappNetworkComponent + this.insertChild(0, network); + if (networkData.fence_mode === 'NAT_ROUTED') { + edgeNetworkCount++; + } else if (networkData.fence_mode === 'ISOLATED') { + this.isolatedNetworkCount++; + } + }); + } + + /** + * Gets the vApp Network data. + */ + get data(): VappNetworkData[] { + return this._vappNetworks; + } + + /** + * Gets vApp network positions by name data for horizontally position matching VNICs. + */ + get networkPositionsByName(): VappNetworkPositionsByName { + return this._networkPositionsByName; + } + + /** + * Sets the vapp network top segment (above the vapp background) and the bottom segment (inside the vapp). + * @param lowestVnicPointByNetworkName lowest vnic position by network name from VmAndVnicListComponent + */ + setTopAndBottomSegments(lowestVnicPointByNetworkName: LowestVnicPointByNetworkName) { + this.networkPathList.forEach(network => { + // top segment above vApp background + network.setTopmostSegmentAndConnection(this.isolatedNetworkCount); + // bottom segment within vApp drawn to the lowest attached VNIC or disconnected if it has no VNICs + if (lowestVnicPointByNetworkName[network.data.name]) { + network.setBottommostSegment(lowestVnicPointByNetworkName[network.data.name].y); + } else { + network.setDisconnected(); + } + if (network.data.fence_mode === 'ISOLATED') { + this.isolatedNetworkCount--; + } + }); + } + + /** + * Clones the bottom segment of vapp networks to separate for scrolling and removes the original segment. + * @param splitPositionY vertical point where the network path should be split for cloning and separation + */ + cloneVmListSegments(splitPositionY: number) { + const clones = new paper.Group(); + clones.applyMatrix = false; + clones.pivot = new paper.Point(0, 0); + clones.position = this._point; + this.networkPathList.forEach(network => { + clones.addChild(network.cloneAndSplit(splitPositionY)); + }); + return clones; + } +} diff --git a/src/components/vapp-network.test.ts b/src/components/vapp-network.test.ts new file mode 100644 index 0000000..abb3ee2 --- /dev/null +++ b/src/components/vapp-network.test.ts @@ -0,0 +1,110 @@ +import { VappNetworkComponent, VappNetworkData } from './vapp-network'; +import * as paper from 'paper'; +import { CONNECTOR_RADIUS, VAPP_PADDING, LABEL_HEIGHT } from '../constants/dimensions'; + +describe('vapp component', () => { + + beforeAll(() => { + const canvasEl = document.createElement('canvas'); + paper.setup(canvasEl); + }); + + test('basic properties', () => { + const vappNetworkData: VappNetworkData = { + uuid: '0', + name: 'A', + vapp_uuid: '', + fence_mode: 'BRIDGED' + }; + const position = new paper.Point(0, 0); + const network = new VappNetworkComponent(vappNetworkData); + expect(network.data.uuid).toBe(vappNetworkData.uuid); + expect(network.data.name).toBe(vappNetworkData.name); + expect(network.data.vapp_uuid).toBe(vappNetworkData.vapp_uuid); + expect(network.data.fence_mode).toBe(vappNetworkData.fence_mode); + expect(network.position.x).toBe(position.x); + expect(network.position.y).toBe(position.y); + expect(network.children.length).toBe(1); // path only + }); + + test('nat-routed topmost segment', () => { + const vappNetworkData: VappNetworkData = { + uuid: '0', + name: 'A', + vapp_uuid: '', + fence_mode: 'NAT_ROUTED' + }; + const edgeCount = 0; + const topmostPointY = 20; + const position = new paper.Point(40, 50); + const isolatedCount = 0; + const network = new VappNetworkComponent(vappNetworkData, position, edgeCount, topmostPointY); + network.setTopmostSegmentAndConnection(isolatedCount); + expect(network.children.length).toBe(3); // path, edge label, and connection + expect(network.path.bounds.top).toBe(-(topmostPointY + CONNECTOR_RADIUS)); + }); + + test('isolated topmost segment', () => { + const vappNetworkData: VappNetworkData = { + uuid: '0', + name: 'A', + vapp_uuid: '', + fence_mode: 'ISOLATED' + }; + const position = new paper.Point(0, 0); + const isolatedCount = 1; + const network = new VappNetworkComponent(vappNetworkData, position); + network.setTopmostSegmentAndConnection(isolatedCount); + expect(network.children.length).toBe(2); // path and connection + // (CONNECTOR_RADIUS + MULTIPLE_ISOLATED_NETWORK_PADDING) * isolatedCount + ISOLATED_NETWORK_PADDING ~ 11/2 + 13 + 5 + expect(network.path.bounds.top).toBe(-23.5); + }); + + test('setBottommostSegment method', () => { + const vappNetworkData: VappNetworkData = { + uuid: '0', + name: 'A', + vapp_uuid: '', + fence_mode: 'ISOLATED' + }; + const pointY = 20; + const network = new VappNetworkComponent(vappNetworkData); + network.setBottommostSegment(pointY); + expect(network.children.length).toBe(1); // path only + expect(network.path.bounds.bottom).toBe(pointY); + }); + + test('setDisconnected method', () => { + const vappNetworkData: VappNetworkData = { + uuid: '0', + name: 'A', + vapp_uuid: '', + fence_mode: 'ISOLATED' + }; + const network = new VappNetworkComponent(vappNetworkData); + network.setDisconnected(); + expect(network.children.length).toBe(2); // path and small connector component + expect(network.path.bounds.bottom).toBe(VAPP_PADDING + LABEL_HEIGHT / 2); + }); + + test('cloneAndSplit method', () => { + const vappNetworkData: VappNetworkData = { + uuid: '0', + name: 'A', + vapp_uuid: '', + fence_mode: 'BRIDGED' + }; + const position = new paper.Point(85, 10); + const network = new VappNetworkComponent(vappNetworkData, position); + const length = 100; + const split = 50; + network.setTopmostSegmentAndConnection(0); + network.setBottommostSegment(length); + const initialSegmentCount = network.path.segments.length; + const clone = network.cloneAndSplit(split); + expect(network.path.segments.length).toBe(initialSegmentCount); // a segment was added and a segment was removed + expect(clone.length).toBe(length - split); + expect(clone.position.x).toBe(network.path.position.x + position.x); + }); + +}); diff --git a/src/components/vapp-network.ts b/src/components/vapp-network.ts new file mode 100644 index 0000000..f9b7193 --- /dev/null +++ b/src/components/vapp-network.ts @@ -0,0 +1,158 @@ +import * as paper from 'paper'; +import { CANVAS_BACKGROUND_COLOR } from '../constants/colors'; +import { IsolatedNetworkLabelComponent } from './isolated-network-label'; +import { VappEdgeLabelComponent } from './vapp-edge-label'; +import { ConnectorComponent } from './connector'; +import { SmallConnectorComponent } from './small-connector'; +import { CONNECTOR_RADIUS, CONNECTOR_MARGIN, VAPP_PADDING, LABEL_HEIGHT, DEFAULT_STROKE_WIDTH, LABEL_BOTTOM_PADDING } + from '../constants/dimensions'; +import { DEFAULT_STROKE_STYLE } from '../constants/styles'; + +const MULTIPLE_ISOLATED_NETWORK_PADDING = 13; +const ISOLATED_NETWORK_PADDING = 5; + +export type FenceMode = 'BRIDGED' | 'NAT_ROUTED' | 'ISOLATED'; + +/** + * Interface for vApp network data. + */ +export interface VappNetworkData { + uuid: string; + name: string; + vapp_uuid: string; + fence_mode: FenceMode; +} + +/** + * Virtual Application Network Visual Component. + */ +export class VappNetworkComponent extends paper.Group { + + readonly _path: paper.Path.Line; + // connection icon or isolated network label at the top of the network path + private connectionComponent: ConnectorComponent | IsolatedNetworkLabelComponent; + readonly edgeLabel: VappEdgeLabelComponent; + readonly isNatRouted: boolean = false; + readonly isIsolated: boolean = false; + // network has no attached vnics + private isDisconnected: boolean = false; + + /** + * Creates a new VappNetworkComponent instance. + * + * @param _vappNetwork the vapp network data + * @param _point the location that the vapp network should be rendered at + * @param edgeNetworkCount the number of nat-routed networks + * @param topmostPointY the y coordinate of the matching org-vdc network it will be connected to + */ + // TODO: 59 is hardcoded to topmostPointY in for the vapp demo. replace it with the matching org-vdc vertical position + // when it's eventually added + constructor(private _vappNetwork: VappNetworkData, + private _point: paper.Point = new paper.Point(0, 0), + private edgeNetworkCount: number = 0, + private topmostPointY: number = 59) { + super(); + this.applyMatrix = false; + this.pivot = new paper.Point(0, 0); + this.position = this._point; + + this.isNatRouted = this._vappNetwork.fence_mode === 'NAT_ROUTED'; + this.isIsolated = this._vappNetwork.fence_mode === 'ISOLATED'; + + // create and start the network path at the vApp top boundary + this._path = new paper.Path.Line({ + segment: new paper.Point(0, 0), + style: DEFAULT_STROKE_STYLE, + parent: this + }); + + // add vapp edge label if needed + if (this.isNatRouted) { + const pointX = -CONNECTOR_MARGIN - CONNECTOR_RADIUS; + const pointY = (LABEL_HEIGHT + LABEL_BOTTOM_PADDING + DEFAULT_STROKE_WIDTH) * edgeNetworkCount + + VAPP_PADDING + LABEL_HEIGHT + LABEL_BOTTOM_PADDING; + this.edgeLabel = new VappEdgeLabelComponent(this._vappNetwork.name, new paper.Point(pointX, pointY)); + this.addChild(this.edgeLabel); + } + } + + /** + * Gets the network path. + */ + get path(): paper.Path.Line { + return this._path; + } + + /** + * Gets the VAppNetworkData. + */ + get data(): VappNetworkData { + return this._vappNetwork; + } + + /** + * Sets the topmost segment outside of the vApp and connection icon. + * @param isolatedCount the number of isolated networks used to determine the topmost point. + */ + setTopmostSegmentAndConnection(isolatedCount: number): void { + // create the topmost point + const topmostPositionY = this.isIsolated + ? (CONNECTOR_RADIUS + MULTIPLE_ISOLATED_NETWORK_PADDING) * isolatedCount + ISOLATED_NETWORK_PADDING + : this.topmostPointY + CONNECTOR_RADIUS; + const topmostPoint = new paper.Point(0, -topmostPositionY); + // add the topmost point to the path + this.path.add(topmostPoint); + // add the connection component based on network's fence mode + this.connectionComponent = this.isIsolated + ? new IsolatedNetworkLabelComponent(this._vappNetwork.name, topmostPoint) + : new ConnectorComponent(topmostPoint, CANVAS_BACKGROUND_COLOR); + this.addChild(this.connectionComponent); + } + + /** + * Sets the bottommost segment if the vApp has at least one attached VNIC. + * @param pointY the y coordinate of the vApp network's lowest attached VNIC. + */ + setBottommostSegment(pointY: number): void { + this._path.add(new paper.Point(0, pointY)); + } + + /** + * Sets the bottommost segment and connection icon if the vApp has no attached VNICs. + */ + setDisconnected(): void { + this.isDisconnected = true; + if (!this.isNatRouted) { + // add the bottommost point and disconnected icon next to the vapp label + this._path.add(new paper.Point(0, VAPP_PADDING + LABEL_HEIGHT / 2)); + this.addChild(new SmallConnectorComponent(this._path.bounds.bottomCenter)); + } else { + // add the bottommost point at the edge label's position + this.path.add(new paper.Point(0, this.edgeLabel.position.y)); + } + } + + /** + * Clones and splits the bottom segment in the VmAndVnicListComponent if scrolling is required. + * @param splitPositionY vertical position where the network path should be split for cloning and separation + */ + cloneAndSplit(splitPositionY: number): paper.Path { + let clone: paper.Path = new paper.Path(); + // disconnected networks (without any attached vnics) would not have a segment in the vm and vnic list component + if (!this.isDisconnected) { + // add new point to split the path at the top of the vm list + this._path.add(new paper.Point(0, splitPositionY)); + const segments = this._path.segments; + // clone last segment + clone = new paper.Path.Line({ + from: segments[3].point, + to: segments[4].point, + style: DEFAULT_STROKE_STYLE + }); + clone.position.x = this.position.x; + // remove the original reference segment + segments[3].remove(); + } + return clone; + } +} diff --git a/src/components/vapp.test.ts b/src/components/vapp.test.ts new file mode 100644 index 0000000..f8dd87a --- /dev/null +++ b/src/components/vapp.test.ts @@ -0,0 +1,81 @@ +import { VappComponent, VappData } from './vapp'; +import * as paper from 'paper'; + +describe('vapp component', () => { + + beforeAll(() => { + const xhrMockClass = () => ({ + open : jest.fn(), + send : jest.fn(), + setRequestHeader: jest.fn() + }); + (window as any).XMLHttpRequest = jest.fn().mockImplementation(xhrMockClass); + const canvasEl = document.createElement('canvas'); + paper.setup(canvasEl); + }); + + test('basic properties', () => { + const vappData: VappData = { + uuid: '', + name: 'Coke RES & BURST', + vapp_networks: [ + { + uuid: '0', + name: 'A', + vapp_uuid: '', + fence_mode: 'BRIDGED' + } + ], + vms: [ + { + uuid: '', + name: 'Alert Resource Non Regression VM', + vapp_uuid: '', + operatingSystem: 'windows7Guest', + vnics: [ + { + vnic_id: 0, + network_name: 'A', + is_connected: true + } + ] + }, + { + uuid: '', + name: 'AutomatedSecurityTest1', + vapp_uuid: '', + operatingSystem: 'windows7Guest', + vnics: [ + { + vnic_id: 0, + network_name: 'A', + is_connected: true + } + ] + }, + { + uuid: '', + name: 'CatalogResourceNonRegression1', + vapp_uuid: '', + operatingSystem: 'ubuntu64Guest', + vnics: [ + { + vnic_id: 0, + network_name: 'A', + is_connected: true + } + ] + } + ] + }; + const position = new paper.Point(20, 15); + const vapp = new VappComponent(vappData, position); + expect(vapp.data.uuid).toBe(vappData.uuid); + expect(vapp.data.name).toBe(vappData.name); + expect(vapp.data.vapp_networks).toBe(vappData.vapp_networks); + expect(vapp.data.vms).toBe(vappData.vms); + expect(vapp.position.x).toBe(position.x); + expect(vapp.position.y).toBe(position.y); + }); + +}); diff --git a/src/components/vapp.ts b/src/components/vapp.ts new file mode 100644 index 0000000..9c1f8af --- /dev/null +++ b/src/components/vapp.ts @@ -0,0 +1,263 @@ +import * as paper from 'paper'; +import { EntityLabelComponent } from './entity-label'; +import { VmData } from './vm'; +import { VmAndVnicListComponent } from './vm-and-vnic-list'; +import { VAPP_BACKGROUND_COLOR } from '../constants/colors'; +import { CONNECTOR_RADIUS, VIEW_BOTTOM_PADDING, DEFAULT_SCROLLBAR_THICKNESS, VAPP_NETWORK_RIGHT_MARGIN, VAPP_PADDING, + DEFAULT_STROKE_WIDTH } from '../constants/dimensions'; +import { MarginComponent } from './margin'; +import { VappNetworkData } from './vapp-network'; +import { VappNetworkListComponent } from './vapp-network-list'; +import { ScrollbarComponent } from './scrollbar'; + +const MARGIN_RIGHT = 30; +const BACKGROUND_RADIUS = 5; +const LABEL_ICON_COLOR = '#CA67B8'; +const VAPP_LABEL_BOTTOM_MARGIN = 10; +const EDGE_LABEL_BOTTOM_MARGIN = 5; + +/** + * Interface for vApp data. + */ +export interface VappData { + uuid: string; + name: string; + vapp_networks: VappNetworkData[]; + vms: VmData[]; +} + +/** + * Virtual Application Visual Component. + */ +export class VappComponent extends paper.Group { + + readonly label: EntityLabelComponent; + readonly background: paper.Path.Rectangle; + readonly _margin: MarginComponent; + readonly vms: VmAndVnicListComponent; + readonly vappNetworks: VappNetworkListComponent; + // position for the division between any labels and vm/vnic list + readonly divisionPositionY: number; + private scrollbar: ScrollbarComponent; + // content needs scrollbar + private _isScrollable: boolean = false; + + /** + * Creates a new VappComponent instance. + * + * @param _vapp the vapp data + * @param _point the location that the vm should be rendered at + */ + constructor(private _vapp: VappData, + private _point: paper.Point = new paper.Point(0, 0)) { + super(); + this.applyMatrix = false; + this.pivot = new paper.Point(0, 0); + this.position = _point; + + let backgroundOffsetX = 0; + let backgroundOffsetY = 0; + const vappNetworkCount = this._vapp.vapp_networks.length; + const vmCount = this._vapp.vms.length; + + // vapp label + // x position based on if there are any vApp networks (and how many) or vms + const labelPositionX = vappNetworkCount || vmCount + ? Math.max(1, vappNetworkCount) * VAPP_NETWORK_RIGHT_MARGIN + VAPP_PADDING + : VAPP_PADDING; + this.label = new EntityLabelComponent( + this._vapp.name, + LABEL_ICON_COLOR, + new paper.Point(labelPositionX, VAPP_PADDING), + 'bold' + ); + this.addChild(this.label); + + // start vapp network paths - add a point to each network to have x position data for matching vnics + // and edge labels to have y position data for vapp header (which includes the vapp label and edge labels) + if (vappNetworkCount) { + this.vappNetworks = new VappNetworkListComponent( + this._vapp.vapp_networks, + new paper.Point(VAPP_PADDING + CONNECTOR_RADIUS, 0)); + this.addChild(this.vappNetworks); + } + + // calculate division position between the vapp header (which includes the vapp label and edge labels) and vm + // and vnic list + const headerBase = this.globalToLocal(this.bounds.bottomLeft).y; + // headerBase will equal vapp label bottom if there are no edge labels. margin based on which label is positionally + // the lowest. + const vappLabelIsBottomMost = headerBase === this.label.bounds.bottom; + const headerBaseMargin = vappLabelIsBottomMost ? VAPP_LABEL_BOTTOM_MARGIN : EDGE_LABEL_BOTTOM_MARGIN; + this.divisionPositionY = headerBase + headerBaseMargin; + + // maximum bottom position for vApp background based on the canvas view size + const maxBottomPosition = this.globalToLocal(paper.view.bounds.bottomLeft).y - VIEW_BOTTOM_PADDING; + + // vms and vnics list + this.vms = new VmAndVnicListComponent( + this._vapp.vms, + this.vappNetworks && this.vappNetworks.networkPositionsByName, + new paper.Point(VAPP_PADDING + DEFAULT_STROKE_WIDTH, this.divisionPositionY)); + this.addChild(this.vms); + + this._isScrollable = this.vms.bounds.bottom > maxBottomPosition; + + // finish VappNetworks - draw the top external segment and internal segment based on matching vnic positions + if (vappNetworkCount) { + this.vappNetworks.setTopAndBottomSegments(this.vms.lowestVnicPointByNetworkName); + // offset based on any edge labels that extend too far left (if it's on the first left-most vapp network) + backgroundOffsetX = VAPP_PADDING - this.vappNetworks.bounds.left; + // offset based on the vapp network paths' top external segment + backgroundOffsetY = -this.vappNetworks.bounds.top + VAPP_PADDING; + } + + // handles case where the vapp edge label is the bottom most child (when a nat-routed network has no attached + // vnics and there are no vms) and updates background offset to accommodate the label's bottom arc icon + if (!vmCount && !vappLabelIsBottomMost) { + backgroundOffsetY += CONNECTOR_RADIUS; + } + + // background - based on the bounds of all current items and placed beneath everything + const content = this.bounds; + this.background = new paper.Path.Rectangle({ + size: new paper.Size( + content.width + VAPP_PADDING * 2 - backgroundOffsetX, + this._isScrollable ? maxBottomPosition : content.height + VAPP_PADDING * 2 - backgroundOffsetY), + point: new paper.Point(0, 0), + radius: BACKGROUND_RADIUS, + fillColor: VAPP_BACKGROUND_COLOR + }); + this.insertChild(0, this.background); + + // set up scrolling if necessary + if (this._isScrollable) { + this.clipAndScrollVmList(); + this.onMouseEnter = this.scrollMouseEnter; + this.onMouseLeave = this.scrollMouseLeave; + } + + // margin - used by other vapps for static or dynamic positioning + this._margin = new MarginComponent(this.background, 0, MARGIN_RIGHT, 0, 0); + this.insertChild(0, this._margin); + } + + /** + * Gets the vApp data. + */ + get data(): VappData { + return this._vapp; + } + + /** + * Gets the margin. + */ + get margin(): MarginComponent { + return this._margin; + } + + /** + * Gets the isScrollable state when the vApp needs a scrollbar. + */ + get isScrollable(): boolean { + return this._isScrollable; + } + + /** + * Sets scroll listening if there's a scrollbar. + * @param event WheelEvent passed from the native HTML canvas. + */ + // TODO: add scroll listener with a better method. it's in parent demo component for now. could create a native canvas + // event observable service similar to the paper.event service? + setScrollListening(event: WheelEvent) { + if (this.scrollbar) { + this.scrollbar.onScroll(event); + } + } + + /** + * Handler for the mouse enter event when there is a scrollbar. + */ + private scrollMouseEnter = (): void => { + this.scrollbar.containerMouseEnter(); + } + + /** + * Handler for the mouse leave event when there is a scrollbar. + */ + private scrollMouseLeave = (): void => { + if (!ScrollbarComponent.anyIsDragging) { + this.scrollbar.containerMouseLeave(); + // TODO: less kludgey way of activating the global default paper tool. it's the last one created in the demo. more + // tools can be added or created in the future, so this index won't always be correct. can create a tool + // service and/or tool stack which creates and destroys paper.tools when items are in or out of the view + // activates the global default tool (view horizontal scrolling in the parent demo component) + paper.tools[paper.tools.length - 1].activate(); + } + } + + /** + * Clips and adds scrolling to the VmAndVnicList component when it's too large for the view. + */ + private clipAndScrollVmList() { + // create drop shadow at the top of the vm list that fades in or out onScroll + const dropShadow = new paper.Path.Rectangle({ + point: new paper.Point(0, 0), + size: new paper.Size(this.bounds.width, this.divisionPositionY), + opacity: 0, + style: { + fillColor: VAPP_BACKGROUND_COLOR, + shadowColor: new paper.Color(0, 0, 0, 0.41), + shadowBlur: 10, + shadowOffset: new paper.Point(0, 2) + } + }); + + // clip mask container that will clip any vm vnic list component items outside of the vapp background + const vmListClipMask = new paper.Path.Rectangle( + new paper.Point(0, this.divisionPositionY), + new paper.Point(this.background.bounds.bottomRight)); + + // clones segment of vapp network paths inside the vm vnic list component to separate for scrolling + const vappNetworkClone = this.vappNetworks.cloneVmListSegments(this.divisionPositionY); + + // items that will be scrollable + const scrollableContent = new paper.Group({ + applyMatrix: false, + children: [vappNetworkClone, this.vms] + }); + + // scrollbar set up + const scrollbarPadding = 5; + this.scrollbar = new ScrollbarComponent({ + content: scrollableContent, + containerBounds: vmListClipMask.bounds, + contentOffsetEnd: VAPP_PADDING / 2 + }, + new paper.Point(vmListClipMask.bounds.right - DEFAULT_SCROLLBAR_THICKNESS - scrollbarPadding, + this.divisionPositionY), + vmListClipMask.bounds.height - VAPP_PADDING, + 'vertical' + ); + // drop shadow fades in or out onScroll + this.scrollbar.setCustomEffects({ + setActive: function() { + dropShadow.opacity = 1; + }, + setNormal: function() { + (dropShadow as any).tweenTo({ + opacity: 0 + }, 150); + } + }); + + // apply clip mask + // tslint:disable-next-line + new paper.Group({ + applyMatrix: false, + children: [vmListClipMask, scrollableContent, this.scrollbar, dropShadow], + clipped: true, + parent: this + }); + } +} diff --git a/src/components/vm-and-vnic-list.test.ts b/src/components/vm-and-vnic-list.test.ts new file mode 100644 index 0000000..c4b248c --- /dev/null +++ b/src/components/vm-and-vnic-list.test.ts @@ -0,0 +1,204 @@ +import { VmAndVnicListComponent, LowestVnicPointByNetworkName } from './vm-and-vnic-list'; +import { VappNetworkPositionsByName } from './vapp-network-list'; +import { VmData } from './vm'; +import * as paper from 'paper'; +import { LABEL_HEIGHT, VM_MARGIN_VERTICAL } from '../constants/dimensions'; + +describe('vapp component', () => { + + beforeAll(() => { + const xhrMockClass = () => ({ + open : jest.fn(), + send : jest.fn(), + setRequestHeader: jest.fn() + }); + (window as any).XMLHttpRequest = jest.fn().mockImplementation(xhrMockClass); + const canvasEl = document.createElement('canvas'); + paper.setup(canvasEl); + }); + + function getExpectedVnicPoints(vmData: VmData[], + networkPositions: VappNetworkPositionsByName, + position: paper.Point): LowestVnicPointByNetworkName { + const expectedVnicPoints: LowestVnicPointByNetworkName = {}; + let networkCount = 0; + vmData.forEach(vm => { + vm.vnics.forEach(vnic => { + if (expectedVnicPoints[vnic.network_name]) { + expectedVnicPoints[vnic.network_name].y += LABEL_HEIGHT + VM_MARGIN_VERTICAL; + } else { + expectedVnicPoints[vnic.network_name] = + new paper.Point(networkPositions[vnic.network_name].x + position.x + 4.5, // 4.5 VAPP_NETWORK_PADDING_RIGHT + networkCount * (LABEL_HEIGHT + VM_MARGIN_VERTICAL) + LABEL_HEIGHT / 2 + VM_MARGIN_VERTICAL + position.y); + networkCount++; + } + }); + }); + return expectedVnicPoints; + } + + test.only('basic properties and vnics on different networks', () => { + const vmsData: VmData[] = [ + { + uuid: '', + name: 'Alert Resource Non Regression VM', + vapp_uuid: '', + operatingSystem: 'windows7Guest', + vnics: [ + { + vnic_id: 0, + network_name: 'A', + is_connected: true + } + ] + }, + { + uuid: '', + name: 'AutomatedSecurityTest1', + vapp_uuid: '', + operatingSystem: 'windows7Guest', + vnics: [ + { + vnic_id: 0, + network_name: 'B', + is_connected: true + } + ] + }, + { + uuid: '', + name: 'CatalogResourceNonRegression1', + vapp_uuid: '', + operatingSystem: 'ubuntu64Guest', + vnics: [ + { + vnic_id: 0, + network_name: 'C', + is_connected: true + } + ] + } + ]; + const networkPositions: VappNetworkPositionsByName = { + A: new paper.Point(0, 30), + B: new paper.Point(20, 30), + C: new paper.Point(40, 30) + }; + const position = new paper.Point(0, 0); + const vms = new VmAndVnicListComponent(vmsData, networkPositions); + vmsData.forEach((data, i) => { + expect(vms.data[i].uuid).toBe(data.uuid); + expect(vms.data[i].name).toBe(data.name); + expect(vms.data[i].operatingSystem).toBe(data.operatingSystem); + expect(vms.data[i].vnics).toBe(data.vnics); + }); + expect(vms.lowestVnicPointByNetworkName).toEqual(getExpectedVnicPoints(vmsData, networkPositions, position)); + expect(vms.position.y).toBe(position.y); + }); + + test.only('multiple vnics on same network', () => { + const vmsData: VmData[] = [ + { + uuid: '', + name: 'Alert Resource Non Regression VM', + vapp_uuid: '', + operatingSystem: 'windows7Guest', + vnics: [ + { + vnic_id: 0, + network_name: 'A', + is_connected: true + } + ] + }, + { + uuid: '', + name: 'AutomatedSecurityTest1', + vapp_uuid: '', + operatingSystem: 'windows7Guest', + vnics: [ + { + vnic_id: 0, + network_name: 'A', + is_connected: true + } + ] + }, + { + uuid: '', + name: 'CatalogResourceNonRegression1', + vapp_uuid: '', + operatingSystem: 'ubuntu64Guest', + vnics: [ + { + vnic_id: 0, + network_name: 'A', + is_connected: true + } + ] + } + ]; + const networkPositions: VappNetworkPositionsByName = { + A: new paper.Point(0, 30) + }; + const position = new paper.Point(20, 50); + const vms = new VmAndVnicListComponent(vmsData, networkPositions, position); + expect(vms.lowestVnicPointByNetworkName).toEqual(getExpectedVnicPoints(vmsData, networkPositions, position)); + expect(vms.position.x).toBe(position.x); + expect(vms.position.y).toBe(position.y); + }); + + test.only('mix vnics on same network or different network', () => { + const vmsData: VmData[] = [ + { + uuid: '', + name: 'Alert Resource Non Regression VM', + vapp_uuid: '', + operatingSystem: 'windows7Guest', + vnics: [ + { + vnic_id: 0, + network_name: 'A', + is_connected: true + } + ] + }, + { + uuid: '', + name: 'AutomatedSecurityTest1', + vapp_uuid: '', + operatingSystem: 'windows7Guest', + vnics: [ + { + vnic_id: 0, + network_name: 'B', + is_connected: true + } + ] + }, + { + uuid: '', + name: 'CatalogResourceNonRegression1', + vapp_uuid: '', + operatingSystem: 'ubuntu64Guest', + vnics: [ + { + vnic_id: 0, + network_name: 'B', + is_connected: true + } + ] + } + ]; + const networkPositions: VappNetworkPositionsByName = { + A: new paper.Point(0, 30), + B: new paper.Point(20, 30) + }; + const position = new paper.Point(60, 10); + const vms = new VmAndVnicListComponent(vmsData, networkPositions, position); + expect(vms.lowestVnicPointByNetworkName).toEqual(getExpectedVnicPoints(vmsData, networkPositions, position)); + expect(vms.position.x).toBe(position.x); + expect(vms.position.y).toBe(position.y); + }); + +}); diff --git a/src/components/vm-and-vnic-list.ts b/src/components/vm-and-vnic-list.ts new file mode 100644 index 0000000..eb1a334 --- /dev/null +++ b/src/components/vm-and-vnic-list.ts @@ -0,0 +1,113 @@ +import * as paper from 'paper'; +import { VmData, VmComponent } from './vm'; +import { VnicComponent } from './vnic'; +import { CONNECTOR_RADIUS, CONNECTOR_SIZE, CONNECTOR_RIGHT_PADDING, DEFAULT_STROKE_WIDTH, VAPP_NETWORK_RIGHT_MARGIN, + LABEL_HEIGHT, VM_MARGIN_VERTICAL } from '../constants/dimensions'; +import { VappNetworkPositionsByName } from './vapp-network-list'; + +const VAPP_NETWORK_PADDING_RIGHT = 4.5; + +/** + * Interface for the lowest vnic point by network name. + */ +export interface LowestVnicPointByNetworkName { + [name: string]: paper.Point; +} + +/** + * Vm List Visual Component. + */ +export class VmAndVnicListComponent extends paper.Group { + + // store lowest vnic point by network name for setting the lowest point of the vapp network path + private _lowestVnicPointByNetworkName: LowestVnicPointByNetworkName = {}; + // x position of the last (furthest right) network in vapp network list used for positioning vms and vnics + readonly lastNetworkPositionX: number = 0; + + /** + * Creates a new VmAndVnicListComponent instance. + * + * @param _vms the vms data + * @param vappNetworkPositionsByName the vapp network positions by name from the VappNetworkListComponent for + * positioning x value of matching vnics + * @param _point the location that the vm and vnic list should be rendered at + */ + constructor(private _vms: Array, + private vappNetworkPositionsByName: VappNetworkPositionsByName = {}, + private _point: paper.Point = new paper.Point(0, 0)) { + super(); + this.applyMatrix = false; + this.pivot = new paper.Point(0, 0); + this.position = _point; + + // reusable calculations + const vappNetworkCount = Object.keys(this.vappNetworkPositionsByName).length; + this.lastNetworkPositionX = vappNetworkCount && (vappNetworkCount - 1) * VAPP_NETWORK_RIGHT_MARGIN + + CONNECTOR_RADIUS - DEFAULT_STROKE_WIDTH; + const vnicOffsetX = vappNetworkCount + ? CONNECTOR_SIZE + CONNECTOR_RIGHT_PADDING + DEFAULT_STROKE_WIDTH * 2 + : VAPP_NETWORK_PADDING_RIGHT; + const vnicOffsetY = LABEL_HEIGHT / 2 + VM_MARGIN_VERTICAL; + const vmOffsetX = CONNECTOR_RADIUS + CONNECTOR_RIGHT_PADDING + DEFAULT_STROKE_WIDTH * 2; + + // draw vms and their vnics + this._vms.forEach((vmData, index) => { + const pointY = (LABEL_HEIGHT + VM_MARGIN_VERTICAL) * index; + vmData.vnics.forEach(vnicData => { + const vnic = new VnicComponent( + vnicData, + new paper.Point(this.getVnicPointX(vnicData.network_name, vnicOffsetX), pointY + vnicOffsetY)); + this.addChild(vnic); + this._lowestVnicPointByNetworkName[vnicData.network_name] = this.localToGlobal(vnic.position); + }); + const vm = new VmComponent( + vmData, + new paper.Point(this.getVmPointX(vmOffsetX),pointY + VM_MARGIN_VERTICAL), + true); + this.addChild(vm); + }); + } + + /** + * Gets array of VM data. + */ + get data(): VmData[] { + return this._vms; + } + + /** + * Gets the lowestVnicPointByNetworkName data. + */ + get lowestVnicPointByNetworkName(): LowestVnicPointByNetworkName { + return this._lowestVnicPointByNetworkName; + } + + /** + * Calculates VNIC's x position based on if it's attached or unattached to a network and items drawn to the left + * of it. + * @param name the name of the vApp network that the VNIC is attached to + * @param offsetX the extra offset added to x position value + */ + private getVnicPointX(name: string, offsetX: number): number { + const pathPosition = this.vappNetworkPositionsByName && this.vappNetworkPositionsByName[name]; + if (pathPosition) { + // position is based on matching vapp network path + return pathPosition.x + VAPP_NETWORK_PADDING_RIGHT; + } else { + // position is based on the item (vnic or vapp network path) that is located furthest to right + const lastChildPositionX = this.lastChild && this.lastChild.position.x; + return Math.max(lastChildPositionX, this.lastNetworkPositionX) + offsetX; + } + } + + /** + * Calculate's VM's x position based on the item (vnic or vapp network path) drawn to the left of it. + * @param offsetX the extra offset added to x position value + */ + private getVmPointX(offsetX: number): number { + const lastVnicAndOffsetX = (this.lastChild instanceof VnicComponent) ? this.lastChild.position.x + offsetX : 0; + const lastNetworkAndOffsetX = this.lastNetworkPositionX && this.lastNetworkPositionX + offsetX; + // position is based on the item (vnic or vapp network path) that is located furthest to the right + return Math.max(lastVnicAndOffsetX, lastNetworkAndOffsetX); + } +} diff --git a/src/components/vm.test.ts b/src/components/vm.test.ts index 7e15ce2..e123847 100644 --- a/src/components/vm.test.ts +++ b/src/components/vm.test.ts @@ -18,15 +18,25 @@ describe('vm component', () => { const canvasEl = document.createElement('canvas'); paper.setup(canvasEl); const vmData: VmData = { - name: 'test', uuid: 'uuid', - operatingSystem: 'asianux3_64Guest' + name: 'sandbox.ts', + vapp_uuid: '', + operatingSystem: 'asianux3_64Guest', + vnics: [ + { + vnic_id: 0, + network_name: 'A', + is_connected: true + } + ] }; const position = new paper.Point(60, 25); const vm = new VmComponent(vmData, position); - expect(vm.getVmData().name).toBe(vmData.name); expect(vm.getVmData().uuid).toBe(vmData.uuid); + expect(vm.getVmData().name).toBe(vmData.name); + expect(vm.getVmData().vapp_uuid).toBe(vmData.vapp_uuid); expect(vm.getVmData().operatingSystem).toBe(vmData.operatingSystem); + expect(vm.getVmData().vnics).toBe(vmData.vnics); expect(vm.position.x).toBe(position.x); expect(vm.position.y).toBe(position.y); expect(vm.getLabelComponent().getTextComponent().position.x).toBe(LABEL_HORIZONTAL_PADDING + VM_ICON_SIZE); diff --git a/src/components/vm.ts b/src/components/vm.ts index 0ef3e12..917ebcc 100644 --- a/src/components/vm.ts +++ b/src/components/vm.ts @@ -4,13 +4,16 @@ import { OperatingSystem } from 'iland-sdk'; import { IconLabelComponent } from './icon-label'; import { CanvasEventService } from '../services/canvas-event-service'; import { Subscription } from 'rxjs'; +import { VnicData } from './vnic'; const SIZE_DELTA_ON_HOVER = 2; export interface VmData { - operatingSystem: OperatingSystem; - name: string; uuid: string; + name: string; + vapp_uuid: string; + operatingSystem: OperatingSystem; + vnics: VnicData[]; } /** @@ -38,7 +41,8 @@ export class VmComponent extends paper.Group { * @param visible whether the component is immediate visible (default is false because typically the component is * rendered with a creation animation */ - constructor(private _vm: VmData, private _point: paper.Point = new paper.Point(0, 0), + constructor(private _vm: VmData, + private _point: paper.Point = new paper.Point(0, 0), visible: boolean = false) { super(); const self = this; @@ -151,7 +155,7 @@ export class VmComponent extends paper.Group { */ private mouseLeave(event: paper.MouseEvent): void { if (this.hovering) { - const result = this.hitTest(event.point); + const result = this._label.hitTest(this.globalToLocal(event.point)); if (!result) { this.hovering = false; (this as any).tween({ @@ -181,7 +185,7 @@ export class VmComponent extends paper.Group { } /** - * Handler for the containting canvas mouse down event. + * Handler for the containing canvas mouse down event. * @param event {paper.MouseEvent} */ private canvasMouseDown(event: paper.MouseEvent): void { diff --git a/src/components/vnic.test.ts b/src/components/vnic.test.ts new file mode 100644 index 0000000..414ef45 --- /dev/null +++ b/src/components/vnic.test.ts @@ -0,0 +1,43 @@ +import { VnicComponent, VnicData } from './vnic'; +import * as paper from 'paper'; + +describe('vnic component', () => { + + beforeAll(() => { + const canvasEl = document.createElement('canvas'); + paper.setup(canvasEl); + }); + + test('basic properties', () => { + const vnicData: VnicData = { + vnic_id: 0, + network_name: 'A', + is_connected: true + }; + const position = new paper.Point(60, 25); + const vnic = new VnicComponent(vnicData, position); + expect(vnic.data.vnic_id).toBe(vnicData.vnic_id); + expect(vnic.data.network_name).toBe(vnicData.network_name); + expect(vnic.data.is_connected).toBe(vnicData.is_connected); + expect(vnic.position.x).toBe(position.x); + expect(vnic.position.y).toBe(position.y); + expect(vnic.children.length).toBe(1); + }); + + test('disconnected vnic', () => { + const vnicData: VnicData = { + vnic_id: 0, + network_name: 'A', + is_connected: false + }; + const position = new paper.Point(-60, 25); + const vnic = new VnicComponent(vnicData, position); + expect(vnic.data.vnic_id).toBe(vnicData.vnic_id); + expect(vnic.data.network_name).toBe(vnicData.network_name); + expect(vnic.data.is_connected).toBe(vnicData.is_connected); + expect(vnic.position.x).toBe(position.x); + expect(vnic.position.y).toBe(position.y); + expect(vnic.children.length).toBe(3); + }); + +}); diff --git a/src/components/vnic.ts b/src/components/vnic.ts new file mode 100644 index 0000000..509aad5 --- /dev/null +++ b/src/components/vnic.ts @@ -0,0 +1,56 @@ +import * as paper from 'paper'; +import { ConnectorComponent } from './connector'; +import { VAPP_BACKGROUND_COLOR } from '../constants/colors'; +import { DEFAULT_STROKE_STYLE } from '../constants/styles'; + +/** + * Interface for vnic data. + */ +export interface VnicData { + vnic_id: number; + network_name: string; + is_connected: boolean; +} + +/** + * Virtual Network Identifier Card Visual Component. + */ +export class VnicComponent extends paper.Group { + + readonly icon: ConnectorComponent; + + /** + * Creates a new VnicComponent instance. + * @param vnic The vnic data. + * @param _point The position where the vnic will be rendered at. Default is (0, 0). + */ + constructor(private _vnic: VnicData, + private _point: paper.Point = new paper.Point(0, 0)) { + super(); + this.applyMatrix = false; + this.position = _point; + + this.icon = new ConnectorComponent(); + this.addChild(this.icon); + + // draw additional icon visual elements (slash and circle cut) if vnic is disconnected + if (!this._vnic.is_connected) { + let disconnectSlash = new paper.Path.Line(new paper.Point(-17 / 2, 0), new paper.Point(17 / 2, 0)); + disconnectSlash.rotate(-45); + disconnectSlash.style = DEFAULT_STROKE_STYLE; + let disconnectCut = disconnectSlash.clone(); + disconnectCut.style = { + strokeWidth: 6, + strokeColor: VAPP_BACKGROUND_COLOR + }; + this.addChildren([disconnectCut, disconnectSlash]); + } + } + + /** + * Gets the VnicData. + */ + get data(): VnicData { + return this._vnic; + } +} diff --git a/src/constants/dimensions.ts b/src/constants/dimensions.ts index 56355bd..112f319 100644 --- a/src/constants/dimensions.ts +++ b/src/constants/dimensions.ts @@ -2,6 +2,19 @@ * Dimensional constants that are shared among multiple components should be defined here. */ +export const DEFAULT_STROKE_WIDTH = 1; +export const VIEW_BOTTOM_PADDING = 35; +export const LABEL_HEIGHT = 30; export const LABEL_HORIZONTAL_PADDING = 9; +export const LABEL_BOTTOM_PADDING = 20; +export const DEFAULT_MAX_LABEL_WIDTH = 200; export const VM_ICON_SIZE = 30; +export const VM_MARGIN_VERTICAL = 10; +export const CONNECTOR_SIZE = 11; +export const CONNECTOR_RADIUS = CONNECTOR_SIZE / 2; +export const CONNECTOR_MARGIN = 10; +export const CONNECTOR_RIGHT_PADDING = 8; +export const SMALL_CONNECTOR_SIZE = 7; export const DEFAULT_SCROLLBAR_THICKNESS = 5; +export const VAPP_PADDING = 20; +export const VAPP_NETWORK_RIGHT_MARGIN = 20; diff --git a/src/constants/styles.ts b/src/constants/styles.ts new file mode 100644 index 0000000..e8ef6a2 --- /dev/null +++ b/src/constants/styles.ts @@ -0,0 +1,10 @@ +/** + * Style constants that are shared among multiple components should be defined here. + */ +import { DEFAULT_STROKE_WIDTH } from './dimensions'; +import { LIGHT_GREY } from './colors'; + +export const DEFAULT_STROKE_STYLE = { + strokeWidth: DEFAULT_STROKE_WIDTH, + strokeColor: LIGHT_GREY +}; From d8924f53b582cd0662102ea753d10c91c5383d59 Mon Sep 17 00:00:00 2001 From: cho Date: Tue, 1 Oct 2019 16:55:03 -0500 Subject: [PATCH 2/2] updates --- .../demo-component/demo.component.ts | 3 + ...isc-scrollbar-horizontal-demo.component.ts | 14 +- .../misc-scrollbar-vertical-demo.component.ts | 12 +- .../vapp-static-demo.component.ts | 41 +--- ...ata.ts => vapp-static-placeholder-data.ts} | 89 +++++++- ...s => bullet-point-connection-icon.test.ts} | 9 +- .../bullet-point-connection-icon.ts | 36 ++++ ...nector.test.ts => connection-icon.test.ts} | 13 +- .../{connector.ts => connection-icon.ts} | 22 +- src/components/entity-label.test.ts | 11 +- src/components/entity-label.ts | 16 +- src/components/icon-label-loader.ts | 4 +- src/components/icon-label.ts | 1 - src/components/isolated-network-label.test.ts | 6 +- src/components/isolated-network-label.ts | 59 ++---- src/components/label-text.test.ts | 65 ++++++ src/components/label-text.ts | 89 ++++++++ src/components/label.test.ts | 39 ++++ src/components/label.ts | 78 ++----- src/components/margin.test.ts | 195 ++++++++++++++++-- src/components/margin.ts | 135 ++++++++++-- src/components/scrollbar.test.ts | 91 +++----- src/components/scrollbar.ts | 169 +++++++++------ src/components/small-connector.ts | 35 ---- src/components/vapp-edge-label.test.ts | 1 + src/components/vapp-edge-label.ts | 8 +- src/components/vapp-network-list.test.ts | 1 + src/components/vapp-network-list.ts | 13 +- src/components/vapp-network.test.ts | 3 +- src/components/vapp-network.ts | 14 +- src/components/vapp.ts | 88 +++----- src/components/vm-and-vnic-list.test.ts | 19 +- src/components/vm-and-vnic-list.ts | 74 +++---- src/components/vm.test.ts | 10 +- src/components/vm.ts | 1 - src/components/vnic.test.ts | 1 + src/components/vnic.ts | 7 +- src/constants/colors.ts | 1 + src/constants/dimensions.ts | 2 +- src/gibraltar.ts | 3 + 40 files changed, 941 insertions(+), 537 deletions(-) rename demo/src/app/constants/{vapp-basic-placeholder-data.ts => vapp-static-placeholder-data.ts} (96%) rename src/components/{small-connector.test.ts => bullet-point-connection-icon.test.ts} (62%) create mode 100644 src/components/bullet-point-connection-icon.ts rename src/components/{connector.test.ts => connection-icon.test.ts} (63%) rename src/components/{connector.ts => connection-icon.ts} (55%) create mode 100644 src/components/label-text.test.ts create mode 100644 src/components/label-text.ts create mode 100644 src/components/label.test.ts delete mode 100644 src/components/small-connector.ts diff --git a/demo/src/app/components/demo-component/demo.component.ts b/demo/src/app/components/demo-component/demo.component.ts index 20ce784..69364ac 100644 --- a/demo/src/app/components/demo-component/demo.component.ts +++ b/demo/src/app/components/demo-component/demo.component.ts @@ -42,6 +42,9 @@ export class DemoComponent implements OnInit { private activeButton: 'RESET' | 'RUN' = 'RUN'; ngOnInit(): void { + // apply this setting globally in the demo, so child components behave relatively to their parent component. + // disable on an individual basis by setting the item's applyMatrix property to `true` + paper.settings.applyMatrix = false; this.project = new paper.Project(this.canvas.nativeElement); this.backgroundColor = DEFAULT_BACKGROUND_COLOR; } diff --git a/demo/src/app/components/misc-scrollbar-demo-component/misc-scrollbar-horizontal-demo.component.ts b/demo/src/app/components/misc-scrollbar-demo-component/misc-scrollbar-horizontal-demo.component.ts index ba41ad1..af39197 100644 --- a/demo/src/app/components/misc-scrollbar-demo-component/misc-scrollbar-horizontal-demo.component.ts +++ b/demo/src/app/components/misc-scrollbar-demo-component/misc-scrollbar-horizontal-demo.component.ts @@ -7,7 +7,7 @@ import { DEFAULT_SCROLLBAR_THICKNESS } from '../../../../../src/constants/dimens @Component({ selector: 'misc-scrollbar-horizontal-demo', - template: ` + template: ` @@ -23,10 +23,8 @@ export class MiscScrollbarHorizontalDemoComponent implements AfterViewInit { // sets up Paper Project const proj = this.demo.getProject(); proj.activate(); - proj.activeLayer.applyMatrix = false; this.demo.backgroundColor = CANVAS_BACKGROUND_COLOR; const view = paper.view; - const canvas = paper.view.element; const VIEW_PADDING = 30; // create content @@ -56,21 +54,13 @@ export class MiscScrollbarHorizontalDemoComponent implements AfterViewInit { // create scrollbar const scrollbar = new ScrollbarComponent({ content: content, + container: view.element, containerBounds: view.bounds, contentOffsetEnd: VIEW_PADDING }, new paper.Point(VIEW_PADDING, view.size.height - DEFAULT_SCROLLBAR_THICKNESS - 10), view.bounds.width - VIEW_PADDING * 2 ); - if (scrollbar.isEnabled) { - canvas.onmouseenter = scrollbar.containerMouseEnter; - canvas.onmouseleave = scrollbar.containerMouseLeave; - - // add scroll listening. paper doesn't have a wheel event handler - canvas.onwheel = (event: WheelEvent) => { - scrollbar.onScroll(event); - }; - } } diff --git a/demo/src/app/components/misc-scrollbar-demo-component/misc-scrollbar-vertical-demo.component.ts b/demo/src/app/components/misc-scrollbar-demo-component/misc-scrollbar-vertical-demo.component.ts index 3854b87..9b681e1 100644 --- a/demo/src/app/components/misc-scrollbar-demo-component/misc-scrollbar-vertical-demo.component.ts +++ b/demo/src/app/components/misc-scrollbar-demo-component/misc-scrollbar-vertical-demo.component.ts @@ -22,10 +22,8 @@ export class MiscScrollbarVerticalDemoComponent implements AfterViewInit { // sets up Paper Project const proj = this.demo.getProject(); proj.activate(); - proj.activeLayer.applyMatrix = false; this.demo.backgroundColor = CANVAS_BACKGROUND_COLOR; const view = paper.view; - const canvas = paper.view.element; const VIEW_PADDING = 30; // create content @@ -55,6 +53,7 @@ export class MiscScrollbarVerticalDemoComponent implements AfterViewInit { // create scrollbar const scrollbar = new ScrollbarComponent({ content: content, + container: view.element, containerBounds: view.bounds, contentOffsetEnd: VIEW_PADDING }, @@ -62,15 +61,6 @@ export class MiscScrollbarVerticalDemoComponent implements AfterViewInit { view.bounds.height - VIEW_PADDING * 2, 'vertical' ); - if (scrollbar.isEnabled) { - canvas.onmouseenter = scrollbar.containerMouseEnter; - canvas.onmouseleave = scrollbar.containerMouseLeave; - - // add scroll listening. paper doesn't have a wheel event handler - canvas.onwheel = (event: WheelEvent) => { - scrollbar.onScroll(event); - }; - } } diff --git a/demo/src/app/components/vapp-static-demo-component/vapp-static-demo.component.ts b/demo/src/app/components/vapp-static-demo-component/vapp-static-demo.component.ts index 633f312..583bdd6 100644 --- a/demo/src/app/components/vapp-static-demo-component/vapp-static-demo.component.ts +++ b/demo/src/app/components/vapp-static-demo-component/vapp-static-demo.component.ts @@ -2,7 +2,7 @@ import { AfterViewInit, Component, ViewChild } from '@angular/core'; import * as paper from 'paper'; import { DemoComponent } from '../demo-component/demo.component'; import { VappData, VappComponent } from '../../../../../src/components/vapp'; -import { placeholderArrayOfVappData } from '../../constants/vapp-basic-placeholder-data'; +import { placeholderArrayOfVappData } from '../../constants/vapp-static-placeholder-data'; import { ScrollbarComponent } from '../../../../../src/components/scrollbar'; import { CANVAS_BACKGROUND_COLOR } from '../../../../../src/constants/colors'; import { CONNECTOR_RADIUS, DEFAULT_SCROLLBAR_THICKNESS } from '../../../../../src/constants/dimensions'; @@ -22,8 +22,7 @@ export class VappStaticDemoComponent implements AfterViewInit { // sets up Paper Project const proj = this.demo.getProject(); proj.activate(); - const view = paper.view; - const canvas = paper.view.element; + const view = proj.view; this.demo.backgroundColor = CANVAS_BACKGROUND_COLOR; const VIEW_PADDING = 30; @@ -31,17 +30,12 @@ export class VappStaticDemoComponent implements AfterViewInit { const VERTICAL_POSITION = VIEW_PADDING + DEMO_VAPP_TOP_ALIGNMENT + CONNECTOR_RADIUS; const vapps: Array = placeholderArrayOfVappData; - const content = new paper.Group({ applyMatrix: false }); - // create origin paper Item for vapps to base position from - const origin = new paper.Path.Circle({ - position: new paper.Point(VIEW_PADDING, VERTICAL_POSITION), - radius: 0, - parent: content - }); + const content = new paper.Group(); // create vapps vapps.forEach(vappData => { - const position = new paper.Point(content.lastChild.bounds.right, VERTICAL_POSITION); + const position = new paper.Point( + content.lastChild ? content.lastChild.bounds.right : VIEW_PADDING, VERTICAL_POSITION); content.addChild(new VappComponent(vappData, position)); }); (content.lastChild as VappComponent).margin.right = 0; @@ -49,6 +43,7 @@ export class VappStaticDemoComponent implements AfterViewInit { // create view horizontal scrollbar const horizontalScrollbar = new ScrollbarComponent({ content: content, + container: view.element, containerBounds: view.bounds, contentOffsetEnd: VIEW_PADDING }, @@ -56,29 +51,7 @@ export class VappStaticDemoComponent implements AfterViewInit { view.bounds.width - VIEW_PADDING * 2, 'horizontal' ); - if (horizontalScrollbar.isEnabled) { - canvas.onmouseenter = horizontalScrollbar.containerMouseEnter; - canvas.onmouseleave = horizontalScrollbar.containerMouseLeave; - } - - // add scroll listening. paper doesn't have a wheel event handler - canvas.onwheel = (event: WheelEvent) => { - // horizontal scrolling sent to horizontal scrollbar - if (Math.abs(event.deltaX) > Math.abs(event.deltaY)) { - horizontalScrollbar.onScroll(event); - } else { - // vertical scrolling sent to any scrollable vapp that's active/hovered - content.children.forEach(item => { - if (item instanceof VappComponent && item.isScrollable) { - item.setScrollListening(event); - } - }); - } - }; - - // TODO: keydown 'left' and 'right' should always go to horizontalScrollbar. keydown 'up' and 'down' to should go to - // any scrollable vapp that's active/hovered. can try handling with a paper tools service and/or tool stack + ScrollbarComponent.defaultScrollbar = horizontalScrollbar; - // TODO: make sure 'Roboto' font loading finishes before canvas elements are rendered } } diff --git a/demo/src/app/constants/vapp-basic-placeholder-data.ts b/demo/src/app/constants/vapp-static-placeholder-data.ts similarity index 96% rename from demo/src/app/constants/vapp-basic-placeholder-data.ts rename to demo/src/app/constants/vapp-static-placeholder-data.ts index 0024156..72f8375 100644 --- a/demo/src/app/constants/vapp-basic-placeholder-data.ts +++ b/demo/src/app/constants/vapp-static-placeholder-data.ts @@ -1,4 +1,5 @@ import { VappData } from '../../../../src/components/vapp'; +import { OperatingSystem } from 'iland-sdk'; /** * Placeholder vApp data for the Vapp Static Demo @@ -23,6 +24,7 @@ import { VappData } from '../../../../src/components/vapp'; // 16. variations for unattached vnics // 17. nat-routed vApp network with no attached vms and vnics // 18. vapp with no vapp networks or vms + // 19. network-less vm in a list that has other vapp networks export const placeholderArrayOfVappData: Array = [ // 0. nat-routed vapp network { @@ -279,13 +281,13 @@ export const placeholderArrayOfVappData: Array = [ vnics: [ { vnic_id: 0, - network_name: 'A', - is_connected: true + network_name: '', + is_connected: false }, { vnic_id: 1, - network_name: '', - is_connected: false + network_name: 'A', + is_connected: true }, { vnic_id: 2, @@ -2060,5 +2062,84 @@ export const placeholderArrayOfVappData: Array = [ name: 'Coke RES & BURST', vapp_networks: [], vms: [] + }, + // 19. network-less vm in a list that has other vapp networks + { + uuid: '', + name: 'BC Test vApp', + vapp_networks: [ + { + uuid: '0', + name: 'A', + vapp_uuid: '', + fence_mode: 'BRIDGED' + }, + { + uuid: '1', + name: 'B', + vapp_uuid: '', + fence_mode: 'BRIDGED' + }, + { + uuid: '2', + name: 'C', + vapp_uuid: '', + fence_mode: 'BRIDGED' + } + ], + vms: [ + { + uuid: '', + name: 'redhat-as-01', + vapp_uuid: '', + operatingSystem: 'redhatGuest', + vnics: [ + { + vnic_id: 0, + network_name: 'A', + is_connected: true + } + ] + }, + { + uuid: '', + name: 'debian-as-02', + vapp_uuid: '', + operatingSystem: 'debian8Guest', + vnics: [ + { + vnic_id: 0, + network_name: 'B', + is_connected: true + } + ] + }, + { + uuid: '', + name: 'linux-as-03', + vapp_uuid: '', + operatingSystem: 'other24xLinux64Guest', + vnics: [ + { + vnic_id: 1, + network_name: 'C', + is_connected: true + } + ] + }, + { + uuid: '', + name: 'arch-as-04', + vapp_uuid: '', + operatingSystem: 'btwIUseArch' as OperatingSystem, + vnics: [ + { + vnic_id: 0, + network_name: '', + is_connected: false + } + ] + } + ] } ]; diff --git a/src/components/small-connector.test.ts b/src/components/bullet-point-connection-icon.test.ts similarity index 62% rename from src/components/small-connector.test.ts rename to src/components/bullet-point-connection-icon.test.ts index f183d24..84861bf 100644 --- a/src/components/small-connector.test.ts +++ b/src/components/bullet-point-connection-icon.test.ts @@ -1,23 +1,24 @@ -import { SmallConnectorComponent } from './small-connector'; +import { BulletPointConnectionIconComponent } from './bullet-point-connection-icon'; import * as paper from 'paper'; -describe('small connector component', () => { +describe('bullet point connection icon component', () => { beforeAll(() => { const canvasEl = document.createElement('canvas'); paper.setup(canvasEl); + paper.settings.applyMatrix = false; }); test('basic properties', () => { const position = new paper.Point(0, 0); - const connector = new SmallConnectorComponent(); + const connector = new BulletPointConnectionIconComponent(); expect(connector.position.x).toBe(position.x); expect(connector.position.y).toBe(position.y); }); test('custom position', () => { const position = new paper.Point(42, -10); - const connector = new SmallConnectorComponent(position); + const connector = new BulletPointConnectionIconComponent(position); expect(connector.position.x).toBe(position.x); expect(connector.position.y).toBe(position.y); }); diff --git a/src/components/bullet-point-connection-icon.ts b/src/components/bullet-point-connection-icon.ts new file mode 100644 index 0000000..1df6616 --- /dev/null +++ b/src/components/bullet-point-connection-icon.ts @@ -0,0 +1,36 @@ +import * as paper from 'paper'; +import { LIGHT_GREY } from '../constants/colors'; +import { SMALL_CONNECTOR_SIZE } from '../constants/dimensions'; + +/** + * Bullet Point Connection Icon Visual Component. + * The small (7px) grey filled circle icon that represents special types of connections or bullet points for labels. + * Used for isolated vApp Network labels or at the end of vApp networks that have no attached VNICs. + */ +export class BulletPointConnectionIconComponent extends paper.Group { + + // the small filled circle icon + readonly _icon: paper.Path.Circle; + + /** + * Creates a new BulletPointConnectionIconComponent instance. + * + * @param _point the location that the bullet connection icon should be rendered at + */ + constructor(private _point: paper.Point = new paper.Point(0, 0)) { + super(); + this.position = _point; + this.pivot = new paper.Point(0, 0); + + this._icon = new paper.Path.Circle({ + position: new paper.Point(0, 0), + radius: SMALL_CONNECTOR_SIZE / 2, + fillColor: LIGHT_GREY, + parent: this + }); + } + + get icon(): paper.Path.Circle { + return this._icon; + } +} diff --git a/src/components/connector.test.ts b/src/components/connection-icon.test.ts similarity index 63% rename from src/components/connector.test.ts rename to src/components/connection-icon.test.ts index d20cfb2..e73ddb5 100644 --- a/src/components/connector.test.ts +++ b/src/components/connection-icon.test.ts @@ -1,30 +1,31 @@ -import { ConnectorComponent } from './connector'; +import { ConnectionIconComponent } from './connection-icon'; import { VAPP_BACKGROUND_COLOR, CANVAS_BACKGROUND_COLOR } from '../constants/colors'; import * as paper from 'paper'; -describe('connector component', () => { +describe('connection icon component', () => { beforeAll(() => { const canvasEl = document.createElement('canvas'); paper.setup(canvasEl); + paper.settings.applyMatrix = false; }); test('basic properties', () => { const position = new paper.Point(0, 0); const defaultColor = new paper.Color(VAPP_BACKGROUND_COLOR); - const connector = new ConnectorComponent(); + const connector = new ConnectionIconComponent(); expect(connector.position.x).toBe(position.x); expect(connector.position.y).toBe(position.y); - expect((connector.connector.fillColor as paper.Color).equals(defaultColor)).toBe(true); + expect((connector.icon.fillColor as paper.Color).equals(defaultColor)).toBe(true); }); test('custom position and fill color', () => { const position = new paper.Point(-20, 30); const color = new paper.Color(CANVAS_BACKGROUND_COLOR); - const connector = new ConnectorComponent(position, color); + const connector = new ConnectionIconComponent(position, color); expect(connector.position.x).toBe(position.x); expect(connector.position.y).toBe(position.y); - expect((connector.connector.fillColor as paper.Color).equals(color)).toBe(true); + expect((connector.icon.fillColor as paper.Color).equals(color)).toBe(true); }); }); diff --git a/src/components/connector.ts b/src/components/connection-icon.ts similarity index 55% rename from src/components/connector.ts rename to src/components/connection-icon.ts index cd88c1b..ba8eb68 100644 --- a/src/components/connector.ts +++ b/src/components/connection-icon.ts @@ -4,27 +4,29 @@ import { CONNECTOR_RADIUS } from '../constants/dimensions'; import { DEFAULT_STROKE_STYLE } from '../constants/styles'; /** - * Connector Visual Component. + * Connection Icon Visual Component. + * The large (11px) open circle icon with a grey stroke that represents connections. Used for VNICs that are + * attached and connected to a vApp Network or for the vApp Network to Org-Vdc network connections. */ -export class ConnectorComponent extends paper.Group { +export class ConnectionIconComponent extends paper.Group { - // the stroked circle item - readonly _connector: paper.Path.Circle; + // the stroked and 'unfilled' circle icon + readonly _icon: paper.Path.Circle; /** - * Creates a new ConnectorComponent instance. + * Creates a new ConnectionIconComponent instance. * - * @param _point the location that the connector should be rendered at + * @param _point the location that the icon should be rendered at * @param _fillColor the inner fill color that usually matches the background color of the element it is on top of + * to make it seem 'unfilled' */ constructor(private _point: paper.Point = new paper.Point(0, 0), private _fillColor: paper.Color | string = VAPP_BACKGROUND_COLOR) { super(); - this.applyMatrix = false; this.position = _point; this.pivot = new paper.Point(0, 0); - this._connector = new paper.Path.Circle( + this._icon = new paper.Path.Circle( { position: new paper.Point(0, 0), radius: CONNECTOR_RADIUS, @@ -34,7 +36,7 @@ export class ConnectorComponent extends paper.Group { }); } - get connector(): paper.Path.Circle { - return this._connector; + get icon(): paper.Path.Circle { + return this._icon; } } diff --git a/src/components/entity-label.test.ts b/src/components/entity-label.test.ts index e27a3d1..eca0487 100644 --- a/src/components/entity-label.test.ts +++ b/src/components/entity-label.test.ts @@ -7,6 +7,7 @@ describe('entity label component', () => { beforeAll(() => { const canvasEl = document.createElement('canvas'); paper.setup(canvasEl); + paper.settings.applyMatrix = false; }); test('basic properties', () => { @@ -19,18 +20,18 @@ describe('entity label component', () => { expect(label.icon.fillColor).toBe(color); }); - test('fontWeight', () => { + test('textOptions', () => { const text = 'foobar'; const position = new paper.Point(10, 90); const color = new paper.Color('blue'); - const fontWeight = 'bold'; - const label = new EntityLabelComponent(text, color, position, fontWeight); + const textOptions = { fontWeight: 'bold' }; + const label = new EntityLabelComponent(text, color, position, textOptions); expect(label.position.x).toBe(position.x); expect(label.position.y).toBe(position.y); expect(label.icon.fillColor).toBe(color); - expect(label.getTextComponent().fontWeight).toBe(fontWeight); + expect(label.text.fontWeight).toBe(textOptions.fontWeight); expect(label.bounds.right - LABEL_HORIZONTAL_PADDING) - .toBe(label.localToGlobal(label.getTextComponent().bounds.bottomRight).x); + .toBe(label.localToGlobal(label.text.bounds.bottomRight).x); }); }); diff --git a/src/components/entity-label.ts b/src/components/entity-label.ts index 6ad9d1c..4be438c 100644 --- a/src/components/entity-label.ts +++ b/src/components/entity-label.ts @@ -1,6 +1,7 @@ import * as paper from 'paper'; import { LABEL_HORIZONTAL_PADDING } from '../constants/dimensions'; import { LabelComponent } from './label'; +import { TextOptions } from './label-text'; const ICON_SIZE = 10; const ICON_MARGIN = 10; @@ -19,14 +20,13 @@ export class EntityLabelComponent extends LabelComponent { * @param _text the text to be displayed on the label * @param iconColor the icon color specific to the entity type * @param _point the location that the entity label should be rendered at - * @param fontWeight the font weight of the label. Defaults to 'normal' in parent + * @param textOptions the paper.PointText options object to customize the text */ constructor(protected _text: string, - private iconColor: paper.Color | string, + protected iconColor: paper.Color | string, protected _point: paper.Point = new paper.Point(0, 0), - private _fontWeight: string | number = '') { - super(_text, _point); - this.applyMatrix = false; + protected textOptions: TextOptions = {}) { + super(_text, _point, textOptions); this.pivot = new paper.Point(0, 0); this._icon = new paper.Path.Rectangle({ @@ -38,12 +38,6 @@ export class EntityLabelComponent extends LabelComponent { parent: this }); - // change in font weight will change the background width and possibly trigger clipping that will be - // handled by the parent - if (this._fontWeight) { - super.setFontWeight('bold'); - } - // reposition and resize other elements to fit the icon this._label.position.x = this._icon.bounds.right + LABEL_HORIZONTAL_PADDING; this._background.bounds.width += ICON_SIZE + ICON_MARGIN; diff --git a/src/components/icon-label-loader.ts b/src/components/icon-label-loader.ts index 88d78ba..40cbf6d 100644 --- a/src/components/icon-label-loader.ts +++ b/src/components/icon-label-loader.ts @@ -1,9 +1,10 @@ import * as paper from 'paper'; import { VM_ICON_SIZE } from '../constants/dimensions'; +import { CANVAS_BACKGROUND_COLOR } from '../constants/colors'; const SLIDEOVER_ANIMATIONS_PER_SECOND = .125; const SIZE = 30; -const BACKGROUND_COLOR = '#191C28'; +const BACKGROUND_COLOR = CANVAS_BACKGROUND_COLOR; const SPINNER_COLOR = '#81A2B6'; const SPINNER_ARC_WIDTH = 3; const SPINNER_ARC_MARGIN = 9; @@ -28,7 +29,6 @@ export class IconLabelLoaderComponent extends paper.Group { symbolPromise: Promise) { super(); const self = this; - this.applyMatrix = false; this.position = _point; this._background = new paper.Path.Rectangle({ rectangle: new paper.Rectangle(0, 0, diff --git a/src/components/icon-label.ts b/src/components/icon-label.ts index 6edf400..87e5348 100644 --- a/src/components/icon-label.ts +++ b/src/components/icon-label.ts @@ -24,7 +24,6 @@ export class IconLabelComponent extends LabelComponent { constructor(protected _text: string, symbolPromise: Promise, protected _point: paper.Point = new paper.Point(0, 0)) { super(_text, _point); - this.applyMatrix = false; this.pivot = new paper.Point(0, 0); const self = this; // tslint:disable-next-line:no-floating-promises diff --git a/src/components/isolated-network-label.test.ts b/src/components/isolated-network-label.test.ts index 4db5520..aefc9c2 100644 --- a/src/components/isolated-network-label.test.ts +++ b/src/components/isolated-network-label.test.ts @@ -1,4 +1,5 @@ import { IsolatedNetworkLabelComponent } from './isolated-network-label'; +import { DEFAULT_MAX_LABEL_WIDTH } from '../constants/dimensions'; import * as paper from 'paper'; describe('isolated network label component', () => { @@ -6,6 +7,7 @@ describe('isolated network label component', () => { beforeAll(() => { const canvasEl = document.createElement('canvas'); paper.setup(canvasEl); + paper.settings.applyMatrix = false; }); test('basic properties', () => { @@ -14,7 +16,7 @@ describe('isolated network label component', () => { const label = new IsolatedNetworkLabelComponent(text, position); expect(label.position.x).toBe(position.x); expect(label.position.y).toBe(position.y); - expect(label.label.content).toBe(text.toUpperCase()); + expect(label.label.text.content).toBe(text.toUpperCase()); }); test('maxWidth', () => { @@ -23,7 +25,7 @@ describe('isolated network label component', () => { const label = new IsolatedNetworkLabelComponent(text, position); expect(label.position.x).toBe(position.x); expect(label.position.y).toBe(position.y); - expect(label.label.bounds.width).toBeLessThan(200); + expect(label.label.bounds.width).toBeLessThan(DEFAULT_MAX_LABEL_WIDTH); }); }); diff --git a/src/components/isolated-network-label.ts b/src/components/isolated-network-label.ts index f3a3658..a80ef66 100644 --- a/src/components/isolated-network-label.ts +++ b/src/components/isolated-network-label.ts @@ -1,7 +1,7 @@ import * as paper from 'paper'; import { LIGHT_GREY } from '../constants/colors'; -import { SmallConnectorComponent } from './small-connector'; -import { DEFAULT_MAX_LABEL_WIDTH } from '../constants/dimensions'; +import { BulletPointConnectionIconComponent } from './bullet-point-connection-icon'; +import { LabelTextComponent } from './label-text'; const LABEL_PADDING_LEFT = 10; @@ -10,60 +10,43 @@ const LABEL_PADDING_LEFT = 10; */ export class IsolatedNetworkLabelComponent extends paper.Group { - readonly _label: paper.PointText; - // icon at the top of the isolated vApp network path - readonly icon: SmallConnectorComponent; + // the label text + private _label: LabelTextComponent; + // small circle icon at the top of the isolated vApp network path + private icon: BulletPointConnectionIconComponent; /** - * Creates a new VappEdgeLabelComponent instance. + * Creates a new IsolatedNetworkLabelComponent instance. * * @param network the network name - * @param _point the location that the vApp edge label should be rendered at - * @param maxWidth the maximum width of the label (text will be truncated with an ellipsis if the max width is - * exceeded) + * @param _point the location that the isolated network label should be rendered at */ constructor(private network: string, - private _point: paper.Point = new paper.Point(0, 0), - private maxWidth: number = DEFAULT_MAX_LABEL_WIDTH) { + private _point: paper.Point = new paper.Point(0, 0)) { super(); - this.applyMatrix = false; this.pivot = new paper.Point(0, 0); this.position = _point; - this.icon = new SmallConnectorComponent(); - this.addChild(this.icon); + this.icon = new BulletPointConnectionIconComponent(); + + this._label = new LabelTextComponent( + this.network.toUpperCase(), + new paper.Point(0, 0),{ + fillColor: LIGHT_GREY, + fontWeight: 'bold', + fontSize: 10 + }); - this._label = new paper.PointText({ - content: this.network.toUpperCase(), - fillColor: LIGHT_GREY, - fontWeight: 'bold', - fontSize: 10, - parent: this - }); // position the label to the right of the icon this._label.bounds.leftCenter = this.icon.bounds.rightCenter.add(new paper.Point(LABEL_PADDING_LEFT, 0)); - this.clip(); + + this.addChildren([this.icon, this._label]); } /** * Gets the label. */ - get label(): paper.PointText { + get label(): LabelTextComponent { return this._label; } - - // TODO: maxWidth and clip is reused from the generic label component. is there a better way to share these? - /** - * Clips the text and inserts an ellipsis to ensure that the max width is not exceeded. - */ - private clip() { - let clipped = false; - while (this._label.bounds.width > this.maxWidth) { - clipped = true; - this._label.content = this._label.content.substring(0, this._label.content.length - 1); - } - if (clipped) { - this._label.content = this._label.content.substring(0, this._label.content.length - 3) + '...'; - } - } } diff --git a/src/components/label-text.test.ts b/src/components/label-text.test.ts new file mode 100644 index 0000000..6abd62d --- /dev/null +++ b/src/components/label-text.test.ts @@ -0,0 +1,65 @@ +import { LabelTextComponent } from './label-text'; +import * as paper from 'paper'; +import { DEFAULT_MAX_LABEL_WIDTH } from '../constants/dimensions'; + +describe('label text component', () => { + + beforeAll(() => { + const canvasEl = document.createElement('canvas'); + paper.setup(canvasEl); + paper.settings.applyMatrix = false; + }); + + test('basic properties', () => { + const text = 'foobar'; + const position = new paper.Point(0, 0); + const label = new LabelTextComponent(text); + expect(label.position.x).toBe(position.x); + expect(label.position.y).toBe(position.y); + expect(label.text.content).toBe(text); + }); + + test('textOptions configuration', () => { + const text = 'old'; + const position = new paper.Point(20, 50); + const textOptions = { + fontWeight: 'bold', + justification: 'right', + fillColor: new paper.Color('blue'), + fontSize: 30, + leading: 35, + content: 'new' + }; + const label = new LabelTextComponent(text, position, textOptions); + expect(label.position.x).toBe(position.x); + expect(label.position.y).toBe(position.y); + expect(label.text.content).toBe(textOptions.content); + expect(label.text.fontWeight).toBe(textOptions.fontWeight); + expect(label.text.justification).toBe(textOptions.justification); + expect(label.text.fillColor).toBe(textOptions.fillColor); + expect(label.text.fontSize).toBe(textOptions.fontSize); + expect(label.text.leading).toBe(textOptions.leading); + }); + + test('default maxWidth', () => { + const text = 'suuuuuuuuuuper duuuuuuuuuuper loooooooooooooooooong name'; + const position = new paper.Point(56, 3); + const label = new LabelTextComponent(text, position, undefined); + expect(label.position.x).toBe(position.x); + expect(label.position.y).toBe(position.y); + expect(label.bounds.width).toBeLessThan(DEFAULT_MAX_LABEL_WIDTH); + }); + + test('custom maxWidth', () => { + const text = 'suuuuuuuuuuper duuuuuuuuuuper loooooooooooooooooong name'; + const position = new paper.Point(10, 90); + const textOptions = { fontFamily: 'serif' }; + const maxWidth = 100; + const label = new LabelTextComponent(text, position, textOptions, maxWidth); + expect(label.position.x).toBe(position.x); + expect(label.position.y).toBe(position.y); + expect(label.text.fontFamily).toBe(textOptions.fontFamily); + expect(label.bounds.width).toBeLessThan(maxWidth); + }); + +}); diff --git a/src/components/label-text.ts b/src/components/label-text.ts new file mode 100644 index 0000000..7df8129 --- /dev/null +++ b/src/components/label-text.ts @@ -0,0 +1,89 @@ +import * as paper from 'paper'; +import { DEFAULT_MAX_LABEL_WIDTH } from '../constants/dimensions'; +import { WHITE } from '../constants/colors'; + +const TEXT_COLOR = WHITE; +export const FONT_SIZE = 13; +const LINE_HEIGHT = 15; +// TODO: custom font loads asynchronously after the canvas renders. this causes any reliant background items to +// prematurely render at the wrong size. re-enable 'Roboto' custom font when a solution for canvas font loading is +// implemented. can try redraw on frame method or redraw once font is loaded to the HTML canvas. +const FONT_FAMILY = 'Roboto'; + +export type TextOptions = Record; + +/** + * Label Text Visual Component. + */ +export class LabelTextComponent extends paper.Group { + + // the label text + protected _label: paper.PointText; + + /** + * Creates a new LabelTextComponent instance. + * + * @param _text the text to be displayed + * @param _point the location that the label text should be rendered at + * @param textOptions the paper.PointText options object to customize the text properties + * @param maxWidth the maximum width of the label (text will be truncated with an ellipsis if the max width is + * exceeded) + * + * @example + * // basic configuration + * const text = new LabelTextComponent('Hello', new paper.Point(10, 30)); + * + * @example + * // with text options + * const textOptions = { + * fontWeight: 'bold', + * justification: 'right', + * fillColor: new paper.Color('blue'), + * fontSize: 30, + * leading: 35 + * } + * const fancyText = new LabelTextComponent('Salutations', new paper.Point(10, 5), textOptions); + */ + constructor(protected _text: string, + protected _point: paper.Point = new paper.Point(0, 0), + protected textOptions: TextOptions = {}, + protected maxWidth = DEFAULT_MAX_LABEL_WIDTH) { + super(); + this.pivot = new paper.Point(0, 0); + this.position = _point; + + this._label = new paper.PointText({ + pivot: new paper.Point(0, 0), + justification: 'left', + fillColor: TEXT_COLOR, + fontSize: FONT_SIZE, + leading: LINE_HEIGHT, + content: _text, + ...textOptions, + parent: this + }); + + this.clip(); + } + + /** + * Gets the text component. + */ + get text(): paper.PointText { + return this._label; + } + + /** + * Clips the text and inserts an ellipsis to ensure that the max width is not exceeded. + */ + private clip() { + let clipped = false; + while (this._label.bounds.width > this.maxWidth) { + clipped = true; + this._label.content = this._label.content.substring(0, this._label.content.length - 1); + } + if (clipped) { + this._label.content = this._label.content.substring(0, this._label.content.length - 3) + '...'; + } + } +} diff --git a/src/components/label.test.ts b/src/components/label.test.ts new file mode 100644 index 0000000..34f59c0 --- /dev/null +++ b/src/components/label.test.ts @@ -0,0 +1,39 @@ +import { LabelComponent } from './label'; +import * as paper from 'paper'; + +describe('entity label component', () => { + + beforeAll(() => { + const canvasEl = document.createElement('canvas'); + paper.setup(canvasEl); + paper.settings.applyMatrix = false; + }); + + test('basic properties', () => { + const text = 'foobarbaz'; + const position = new paper.Point(0, 0); + const label = new LabelComponent(text); + expect(label.position.x).toBe(position.x); + expect(label.position.y).toBe(position.y); + expect(label.text.content).toBe(text); + }); + + test('background component', () => { + const text = 'hello world'; + const position = new paper.Point(2, 448); + const label = new LabelComponent(text, position); + const styleOptions = { + fillColor: new paper.Color('green'), + strokeWidth: 2, + strokeColor: new paper.Color('pink') + }; + label.background.style = styleOptions; + expect(label.position.x).toBe(position.x); + expect(label.position.y).toBe(position.y); + expect(label.text.content).toBe(text); + expect(label.background.fillColor).toBe(styleOptions.fillColor); + expect(label.background.strokeWidth).toBe(styleOptions.strokeWidth); + expect(label.background.strokeColor).toBe(styleOptions.strokeColor); + }); + +}); diff --git a/src/components/label.ts b/src/components/label.ts index bece167..91a1e81 100644 --- a/src/components/label.ts +++ b/src/components/label.ts @@ -1,24 +1,21 @@ import * as paper from 'paper'; import { LABEL_HORIZONTAL_PADDING, LABEL_HEIGHT, DEFAULT_MAX_LABEL_WIDTH } from '../constants/dimensions'; +import { LabelTextComponent, TextOptions, FONT_SIZE } from './label-text'; +import { WHITE, CANVAS_BACKGROUND_COLOR } from '../constants/colors'; -const TEXT_COLOR = '#FFFFFF'; -const TEXT_HOVER_COLOR = '#FFFFFF'; +const TEXT_COLOR = WHITE; +const TEXT_HOVER_COLOR = WHITE; const ACTIVE_TEXT_COLOR = '#252A3A'; -const ACTIVE_BACKGROUND_COLOR = '#FFFFFF'; -const BACKGROUND_COLOR = '#191C28'; +const ACTIVE_BACKGROUND_COLOR = WHITE; +const BACKGROUND_COLOR = CANVAS_BACKGROUND_COLOR; const HOVER_BACKGROUND_COLOR = '#242A3B'; export const VERTICAL_PADDING_TOP = 6; -export const FONT_SIZE = 13; -const LINE_HEIGHT = 15; -const FONT_FAMILY = 'Roboto'; /** * Label Visual Component. */ -export class LabelComponent extends paper.Group { +export class LabelComponent extends LabelTextComponent { - // the text label - protected _label: paper.PointText; // the background item protected _background: paper.Path.Rectangle; @@ -27,61 +24,38 @@ export class LabelComponent extends paper.Group { * * @param _text the text to be displayed on the label * @param _point the location to render the label at + * @param textOptions the paper.PointText options object to customize the text properties * @param maxWidth the maximum width of the label (text will be truncated with an ellipsis if the max width is * exceeded) */ constructor(protected _text: string, protected _point: paper.Point = new paper.Point(0, 0), - protected _maxWidth = DEFAULT_MAX_LABEL_WIDTH) { - super(); - this.applyMatrix = false; + protected textOptions: TextOptions = {}, + protected maxWidth = DEFAULT_MAX_LABEL_WIDTH) { + super(_text, _point, textOptions, maxWidth); this.pivot = new paper.Point(0, 0); this.position = _point; - this._label = new paper.PointText(new paper.Point(LABEL_HORIZONTAL_PADDING, VERTICAL_PADDING_TOP + - FONT_SIZE)); - this._label.justification = 'left'; - this._label.fillColor = TEXT_COLOR; - this._label.content = _text; - this._label.fontSize = FONT_SIZE; - this._label.leading = LINE_HEIGHT; - this._label.pivot = new paper.Point(0, 0); - this._label.fontFamily = FONT_FAMILY; - this.clip(); + + this._label.position = new paper.Point(LABEL_HORIZONTAL_PADDING, VERTICAL_PADDING_TOP + FONT_SIZE); + this._background = new paper.Path.Rectangle({ rectangle: new paper.Rectangle(0, 0, this._label.bounds.width + (LABEL_HORIZONTAL_PADDING * 2), LABEL_HEIGHT), - radius: 3 + radius: 3, + fillColor: BACKGROUND_COLOR, + pivot: new paper.Point(0, 0) }); - this._background.fillColor = BACKGROUND_COLOR; - this._background.pivot = new paper.Point(0, 0); - this.addChild(this._background); - this.addChild(this._label); - } - /** - * Gets the label text component. - */ - getTextComponent(): paper.PointText { - return this._label; + this.addChildren([this._background, this._label]); } /** * Gets the label background component. */ - getBackgroundComponent(): paper.Path.Rectangle { + get background(): paper.Path.Rectangle { return this._background; } - /** - * Sets the font weight for the label. Updates background width to fit font change and checks for clipping. - * @param weight String or number value for font weight. - */ - setFontWeight(weight: string | number) { - this._label.fontWeight = weight; - this.clip(); - this._background.bounds.width = this._label.bounds.width + (LABEL_HORIZONTAL_PADDING * 2); - } - /** * Sets the label to its visual hover state. */ @@ -106,18 +80,4 @@ export class LabelComponent extends paper.Group { this._background.fillColor = ACTIVE_BACKGROUND_COLOR; } - /** - * Clips the text and inserts an ellipsis to ensure that the max width is not exceeded. - */ - private clip() { - let clipped = false; - while (this._label.bounds.width > this._maxWidth) { - clipped = true; - this._label.content = this._label.content.substring(0, this._label.content.length - 1); - } - if (clipped) { - this._label.content = this._label.content.substring(0, this._label.content.length - 3) + '...'; - } - } - } diff --git a/src/components/margin.test.ts b/src/components/margin.test.ts index 33b17bc..c8407eb 100644 --- a/src/components/margin.test.ts +++ b/src/components/margin.test.ts @@ -1,4 +1,4 @@ -import { MarginComponent } from './margin'; +import { MarginComponent, MarginValues } from './margin'; import * as paper from 'paper'; describe('margin component', () => { @@ -6,6 +6,7 @@ describe('margin component', () => { beforeAll(() => { const canvasEl = document.createElement('canvas'); paper.setup(canvasEl); + paper.settings.applyMatrix = false; }); function createContent() { @@ -29,7 +30,82 @@ describe('margin component', () => { expect(margin.position.y).toBe(content.position.y); }); - test('initialized with one value', () => { + test('initialized with a MarginValues instance', () => { + const content = createContent(); + const marginValue: MarginValues = { + top: 5, + right: 21, + bottom: 74, + left: 12 + }; + const margin = new MarginComponent(content, marginValue); + expect(margin.bounds.size.height).toBe(content.bounds.height + marginValue.top + marginValue.bottom); + expect(margin.bounds.size.width).toBe(content.bounds.width + marginValue.right + marginValue.left); + expect(margin.bounds.top).toBe(content.bounds.top - marginValue.top); + expect(margin.bounds.right).toBe(content.bounds.right + marginValue.right); + expect(margin.bounds.bottom).toBe(content.bounds.bottom + marginValue.bottom); + expect(margin.bounds.left).toBe(content.bounds.left - marginValue.left); + expect(margin.position.x).toBe(content.position.x - marginValue.left); + expect(margin.position.y).toBe(content.position.y - marginValue.top); + }); + + test('initialized with a top and left partial MarginValues instance', () => { + const content = createContent(); + const marginValue: Partial = { + top: 25, + left: 95 + }; + const testValue = marginValue as any; // to avoid object is possibly undefined error + const margin = new MarginComponent(content, marginValue); + expect(margin.bounds.size.height).toBe(content.bounds.height + testValue.top); + expect(margin.bounds.size.width).toBe(content.bounds.width + testValue.left); + expect(margin.bounds.top).toBe(content.bounds.top - testValue.top); + expect(margin.bounds.right).toBe(content.bounds.right); + expect(margin.bounds.bottom).toBe(content.bounds.bottom); + expect(margin.bounds.left).toBe(content.bounds.left - testValue.left); + expect(margin.position.x).toBe(content.position.x - testValue.left); + expect(margin.position.y).toBe(content.position.y - testValue.top); + }); + + test('initialized with a right and bottom partial MarginValues instance', () => { + const content = createContent(); + const marginValue: Partial = { + right: 51, + bottom: 32 + }; + const testValue = marginValue as any; + const margin = new MarginComponent(content, marginValue); + expect(margin.bounds.size.height).toBe(content.bounds.height + testValue.bottom); + expect(margin.bounds.size.width).toBe(content.bounds.width + testValue.right); + expect(margin.bounds.top).toBe(content.bounds.top); + expect(margin.bounds.right).toBe(content.bounds.right + testValue.right); + expect(margin.bounds.bottom).toBe(content.bounds.bottom + testValue.bottom); + expect(margin.bounds.left).toBe(content.bounds.left); + expect(margin.position.x).toBe(content.position.x); + expect(margin.position.y).toBe(content.position.y); + }); + + test('initialized with four values in css shorthand notation', () => { + const content = createContent(); + const marginValue = { + top: 10, + right: 20, + bottom: 15, + left: 30 + }; + const margin = new MarginComponent( + content, marginValue.top, marginValue.right, marginValue.bottom, marginValue.left); + expect(margin.bounds.size.height).toBe(content.bounds.height + marginValue.top + marginValue.bottom); + expect(margin.bounds.size.width).toBe(content.bounds.width + marginValue.right + marginValue.left); + expect(margin.bounds.top).toBe(content.bounds.top - marginValue.top); + expect(margin.bounds.right).toBe(content.bounds.right + marginValue.right); + expect(margin.bounds.bottom).toBe(content.bounds.bottom + marginValue.bottom); + expect(margin.bounds.left).toBe(content.bounds.left - marginValue.left); + expect(margin.position.x).toBe(content.position.x - marginValue.left); + expect(margin.position.y).toBe(content.position.y - marginValue.top); + }); + + test('initialized with one value in css shorthand notation', () => { const content = createContent(); const marginValue = 20; const margin = new MarginComponent(content, marginValue); @@ -43,7 +119,7 @@ describe('margin component', () => { expect(margin.position.y).toBe(content.position.y - marginValue); }); - test('initialized with two values', () => { + test('initialized with two values in css shorthand notation', () => { const content = createContent(); const marginValue = { vertical: 10, @@ -60,7 +136,7 @@ describe('margin component', () => { expect(margin.position.y).toBe(content.position.y - marginValue.vertical); }); - test('initialized with three values', () => { + test('initialized with three values in css shorthand notation', () => { const content = createContent(); const marginValue = { top: 10, @@ -78,7 +154,7 @@ describe('margin component', () => { expect(margin.position.y).toBe(content.position.y - marginValue.top); }); - test('initialized with four values', () => { + test('initialized with four values in css shorthand notation', () => { const content = createContent(); const marginValue = { top: 10, @@ -98,7 +174,92 @@ describe('margin component', () => { expect(margin.position.y).toBe(content.position.y - marginValue.top); }); - test('setValues', () => { + test('setValues with a MarginValues instance', () => { + const content = createContent(); + const originalMarginValue = { + top: 62, + right: 5, + bottom: 44, + left: 67 + }; + const marginValue: MarginValues = { + top: 7, + right: 37, + bottom: 1, + left: 64 + }; + const deltaTop = marginValue.top - originalMarginValue.top; + const deltaLeft = marginValue.left - originalMarginValue.left; + const margin = new MarginComponent(content, + originalMarginValue.top, originalMarginValue.right, originalMarginValue.bottom, originalMarginValue.left); + margin.setValues(marginValue); + expect(margin.bounds.size.height).toBe(content.bounds.height + marginValue.top + marginValue.bottom); + expect(margin.bounds.size.width).toBe(content.bounds.width + marginValue.right + marginValue.left); + expect(margin.bounds.top).toBe(content.bounds.top - marginValue.top - deltaTop); + expect(margin.bounds.right).toBe(content.bounds.right + marginValue.right - deltaLeft); + expect(margin.bounds.bottom).toBe(content.bounds.bottom + marginValue.bottom - deltaTop); + expect(margin.bounds.left).toBe(content.bounds.left - marginValue.left - deltaLeft); + expect(margin.position.x).toBe(content.position.x - marginValue.left - deltaLeft); + expect(margin.position.y).toBe(content.position.y - marginValue.top - deltaTop); + }); + + test('setValues with a top and left partial MarginValues instance', () => { + const content = createContent(); + const originalMarginValue = { + top: 73, + right: 8, + bottom: 90, + left: 42 + }; + const marginValue: Partial = { + top: 22, + left: 51 + }; + const testValue = marginValue as any; + const deltaTop = testValue.top - originalMarginValue.top; + const deltaLeft = testValue.left - originalMarginValue.left; + const margin = new MarginComponent(content, + originalMarginValue.top, originalMarginValue.right, originalMarginValue.bottom, originalMarginValue.left); + margin.setValues(marginValue); + expect(margin.bounds.size.height).toBe(content.bounds.height + testValue.top + originalMarginValue.bottom); + expect(margin.bounds.size.width).toBe(content.bounds.width + originalMarginValue.right + testValue.left); + expect(margin.bounds.top).toBe(content.bounds.top - testValue.top - deltaTop); + expect(margin.bounds.right).toBe(content.bounds.right + originalMarginValue.right - deltaLeft); + expect(margin.bounds.bottom).toBe(content.bounds.bottom + originalMarginValue.bottom - deltaTop); + expect(margin.bounds.left).toBe(content.bounds.left - testValue.left - deltaLeft); + expect(margin.position.x).toBe(content.position.x - testValue.left - deltaLeft); + expect(margin.position.y).toBe(content.position.y - testValue.top - deltaTop); + }); + + test('setValues with a right and bottom partial MarginValues instance', () => { + const content = createContent(); + const originalMarginValue = { + top: 26, + right: 25, + bottom: 6, + left: 2 + }; + const marginValue: Partial = { + right: 10, + bottom: 10 + }; + const testValue = marginValue as any; + const deltaTop = originalMarginValue.top - originalMarginValue.top; + const deltaLeft = originalMarginValue.left - originalMarginValue.left; + const margin = new MarginComponent(content, + originalMarginValue.top, originalMarginValue.right, originalMarginValue.bottom, originalMarginValue.left); + margin.setValues(marginValue); + expect(margin.bounds.size.height).toBe(content.bounds.height + originalMarginValue.top + testValue.bottom); + expect(margin.bounds.size.width).toBe(content.bounds.width + testValue.right + originalMarginValue.left); + expect(margin.bounds.top).toBe(content.bounds.top - originalMarginValue.top - deltaTop); + expect(margin.bounds.right).toBe(content.bounds.right + testValue.right - deltaLeft); + expect(margin.bounds.bottom).toBe(content.bounds.bottom + testValue.bottom - deltaTop); + expect(margin.bounds.left).toBe(content.bounds.left - originalMarginValue.left - deltaLeft); + expect(margin.position.x).toBe(content.position.x - originalMarginValue.left - deltaLeft); + expect(margin.position.y).toBe(content.position.y - originalMarginValue.top - deltaTop); + }); + + test('setValues with css shorthand notation', () => { const content = createContent(); const originalMarginValue = { top: 10, @@ -107,22 +268,24 @@ describe('margin component', () => { left: 30 }; const marginValue = { - top: 10, - right: 20, - bottom: 15, - left: 30 + top: 22, + right: 63, + bottom: 35, + left: 3 }; + const deltaTop = marginValue.top - originalMarginValue.top; + const deltaLeft = marginValue.left - originalMarginValue.left; const margin = new MarginComponent(content, originalMarginValue.top, originalMarginValue.right, originalMarginValue.bottom, originalMarginValue.left); margin.setValues(marginValue.top, marginValue.right, marginValue.bottom, marginValue.left); expect(margin.bounds.size.height).toBe(content.bounds.height + marginValue.top + marginValue.bottom); expect(margin.bounds.size.width).toBe(content.bounds.width + marginValue.right + marginValue.left); - expect(margin.bounds.top).toBe(content.bounds.top - marginValue.top); - expect(margin.bounds.right).toBe(content.bounds.right + marginValue.right); - expect(margin.bounds.bottom).toBe(content.bounds.bottom + marginValue.bottom); - expect(margin.bounds.left).toBe(content.bounds.left - marginValue.left); - expect(margin.position.x).toBe(content.position.x - marginValue.left); - expect(margin.position.y).toBe(content.position.y - marginValue.top); + expect(margin.bounds.top).toBe(content.bounds.top - marginValue.top - deltaTop); + expect(margin.bounds.right).toBe(content.bounds.right + marginValue.right - deltaLeft); + expect(margin.bounds.bottom).toBe(content.bounds.bottom + marginValue.bottom - deltaTop); + expect(margin.bounds.left).toBe(content.bounds.left - marginValue.left - deltaLeft); + expect(margin.position.x).toBe(content.position.x - marginValue.left - deltaLeft); + expect(margin.position.y).toBe(content.position.y - marginValue.top - deltaTop); }); test('set top', () => { diff --git a/src/components/margin.ts b/src/components/margin.ts index b06e6bd..3019c48 100644 --- a/src/components/margin.ts +++ b/src/components/margin.ts @@ -5,7 +5,7 @@ const DEFAULT_MARGIN = 0; /** * Interface for margin values. */ -export interface MarginValues { +export class MarginValues { top: number; right: number; bottom: number; @@ -16,37 +16,127 @@ export interface MarginValues { * Margin Component. */ export class MarginComponent extends paper.Group { - private marginValues: MarginValues; + + private marginValues: MarginValues = { + top: DEFAULT_MARGIN, + right: DEFAULT_MARGIN, + bottom: DEFAULT_MARGIN, + left: DEFAULT_MARGIN + }; + // the surrounding margin rectangle private margin: paper.Path.Rectangle; + /** + * Creates a new margin instance with a partial MarginValues instance. + * + * @param content The content that the margin surrounds. + * @param marginValues Values for the margin sides. Can set `top`, `right`, `bottom`, or `left` partially with + * an instance of MarginValues. Default is 0 for all margins. + * + * @example + * // all values assigned + * const marginValues: MarginValues = { + * top: 5, + * right: 21, + * bottom: 74, + * left: 12 + * }; + * const margin = new MarginComponent((your content), marginValues); + * + * @example + * // values partially assigned + * const marginValues: Partial = { + * top: 5, + * right: 21 + * }; + * const margin = new MarginComponent((your content), marginValues); + */ + constructor(content: paper.Item | paper.Group, marginValues: Partial); + /** + * Creates a new margin instance with margin values written in CSS shorthand notation. + * + * @param content The content that the margin surrounds. + * @param marginValuesCssShorthand Values for the margin sides. Can have a series of 0 to 4 that are comma separated + * in CSS shorthand notation. Default is 0 for all margins. + * + * @example + * // initialized with all four values in css shorthand notation - top, right, bottom, left + * const margin = new MarginComponent((your content), 5, 21, 74, 12); + * + * @example + * // initialized with two values in css shorthand notation - top/bottom, sides + * const margin = new MarginComponent((your content), 10, 15); + */ + constructor(content: paper.Item | paper.Group, ...marginValuesCssShorthand: number[]); /** * Creates a new margin instance. * * @param content The content that the margin surrounds. - * @param marginValues Values for the margin sides. Can have a series of 0 to 4 that are comma separated in CSS - * shorthand order. Default is 0 for all margins. + * @param values Values for the margin side. Implementation of overloaded marginValues: Partial and + * marginValuesCssShorthand: number[]. */ - constructor(private content: paper.Item | paper.Group, ...marginValues: number[]) { + constructor(private content: paper.Item | paper.Group, ...values: Partial[] | number[]) { super(); - this.applyMatrix = false; this.pivot = new paper.Point(0, 0); - this.marginValues = this.assignMarginValues(marginValues); + this.marginValues = this.assignMarginValues(values); this.createAndPositionMargin(); } + /** + * Sets new margin values with a partial MarginValues instance. + * + * @param marginValues Values for the margin sides. Can override current `top`, `right`, `bottom`, or `left` + * values partially with an instance of MarginValues. Default is the original assignment from instantiation for + * all margins. + * + * @example + * // all values set + * const marginValues: MarginValues = { + * top: 5, + * right: 21, + * bottom: 74, + * left: 12 + * }; + * marginInstance.setValues(marginValues); + * + * @example + * // values partially set + * const marginValues: Partial = { + * top: 5, + * right: 21 + * }; + * marginInstance.setValues(marginValues); + */ + setValues(marginValues: Partial): void; + /** + * Sets new margin values written in CSS shorthand notation. + * + * @param marginValuesCssShorthand Values for the margin sides. Can have a series of 0 to 4 that are comma separated + * in CSS shorthand notation. Default is 0 for all margins. + * + * @example + * // set all four values in css shorthand notation - top, right, bottom, left + * marginInstance.setValues(5, 21, 74, 12); + * + * @example + * // set with two values in css shorthand notation - top/bottom, sides + * marginInstance.setValues(10, 15); + */ + setValues(...marginValuesCssShorthand: number[]): void; /** * Sets new margin values. - * @param values Series of number values. Can have 0 to 4 that are comma separated in CSS shorthand order. Default - * is 0 for all margins. + * + * @param values Values for the margin side. Implementation of overloaded marginValues: Partial and + * marginValuesCssShorthand: number[]. */ - setValues(...values: number[]): void { - const top = this.marginValues.top; - const left = this.marginValues.left; + setValues(...values: Partial[] | number[]): void { + const previousTop = this.marginValues.top; + const previousLeft = this.marginValues.left; this.marginValues = this.assignMarginValues(values); this.rebuildMargin(); - this.content.position.y += this.marginValues.top - top; - this.content.position.x += this.marginValues.left - left; + this.content.position.y += this.marginValues.top - previousTop; + this.content.position.x += this.marginValues.left - previousLeft; } /** @@ -90,10 +180,21 @@ export class MarginComponent extends paper.Group { } /** - * Assigns margin values based on CSS shorthand style. - * @param values + * Assigns margin values according to the type of the first value. + * @param values Margin values. + */ + private assignMarginValues(values: Partial[] | number[]): MarginValues { + const firstValue = values[0]; + return typeof firstValue === 'number' + ? this.assignCssStyleMarginValues(values as number[]) + : { ...this.marginValues, ...firstValue }; + } + + /** + * Assigns margin values based on CSS shorthand notation. + * @param values Margin values. */ - private assignMarginValues(values: number[]): MarginValues { + private assignCssStyleMarginValues(values: number[]): MarginValues { let top; let right; let bottom; diff --git a/src/components/scrollbar.test.ts b/src/components/scrollbar.test.ts index 1e4e311..b0dd023 100644 --- a/src/components/scrollbar.test.ts +++ b/src/components/scrollbar.test.ts @@ -6,14 +6,13 @@ describe('scrollbar component', () => { beforeAll(() => { const canvasEl = document.createElement('canvas'); paper.setup(canvasEl); + paper.settings.applyMatrix = false; }); test('basic properties and defaults', () => { const scrollable = { - content: new paper.Path.Rectangle( - new paper.Point(10, 0), - new paper.Size(1000, 500) - ), + content: new paper.Path.Rectangle(new paper.Rectangle(10, 0, 1000, 500)), + container: new paper.Path.Rectangle(new paper.Rectangle(0, 0, 500, 500)), containerBounds: new paper.Rectangle(0, 0, 500, 500) }; const defaultPosition = new paper.Point(0, 0); @@ -31,10 +30,8 @@ describe('scrollbar component', () => { test('basic properties and custom position', () => { const scrollable = { - content: new paper.Path.Rectangle( - new paper.Point(10, 0), - new paper.Size(1000, 500) - ), + content: new paper.Path.Rectangle(new paper.Rectangle(10, 0, 1000, 500)), + container: new paper.Path.Rectangle(new paper.Rectangle(0, 0, 500, 500)), containerBounds: new paper.Rectangle(0, 0, 500, 500) }; const customPosition = new paper.Point(30, 30); @@ -53,10 +50,8 @@ describe('scrollbar component', () => { test('basic properties and custom scrollTrackLength', () => { const scrollable = { - content: new paper.Path.Rectangle( - new paper.Point(10, 0), - new paper.Size(1000, 500) - ), + content: new paper.Path.Rectangle(new paper.Rectangle(10, 0, 1000, 500)), + container: new paper.Path.Rectangle(new paper.Rectangle(0, 0, 500, 500)), containerBounds: new paper.Rectangle(0, 0, 500, 500) }; const defaultPosition = new paper.Point(0, 0); @@ -75,10 +70,8 @@ describe('scrollbar component', () => { test('does not build when content fits inside container' , () => { const scrollable = { - content: new paper.Path.Rectangle( - new paper.Point(0, 0), - new paper.Size(500, 500) - ), + content: new paper.Path.Rectangle(new paper.Rectangle(0, 0, 500, 500)), + container: new paper.Path.Rectangle(new paper.Rectangle(0, 0, 500, 500)), containerBounds: new paper.Rectangle(0, 0, 500, 500) }; const scrollbar = new ScrollbarComponent(scrollable, new paper.Point(0, 0)); @@ -87,10 +80,8 @@ describe('scrollbar component', () => { test('builds when content does not fit inside container' , () => { const scrollable = { - content: new paper.Path.Rectangle( - new paper.Point(0, 0), - new paper.Size(501, 500) - ), + content: new paper.Path.Rectangle(new paper.Rectangle(0, 0, 501, 500)), + container: new paper.Path.Rectangle(new paper.Rectangle(0, 0, 500, 500)), containerBounds: new paper.Rectangle(0, 0, 500, 500) }; const scrollbar = new ScrollbarComponent(scrollable, new paper.Point(0, 0)); @@ -99,10 +90,8 @@ describe('scrollbar component', () => { test('does not build when content including offsets do not fit but checkFitWithOffsets is off' , () => { const scrollable = { - content: new paper.Path.Rectangle( - new paper.Point(0, 0), - new paper.Size(500, 500) - ), + content: new paper.Path.Rectangle(new paper.Rectangle(0, 0, 500, 500)), + container: new paper.Path.Rectangle(new paper.Rectangle(0, 0, 500, 500)), containerBounds: new paper.Rectangle(0, 0, 500, 500), contentOffsetStart: 10, contentOffsetEnd: 10, @@ -114,10 +103,8 @@ describe('scrollbar component', () => { test('builds when content including offsets do not fit and checkFitWithOffsets is on' , () => { const scrollable = { - content: new paper.Path.Rectangle( - new paper.Point(0, 0), - new paper.Size(500, 500) - ), + content: new paper.Path.Rectangle(new paper.Rectangle(0, 0, 500, 500)), + container: new paper.Path.Rectangle(new paper.Rectangle(0, 0, 500, 500)), containerBounds: new paper.Rectangle(0, 0, 500, 500), contentOffsetStart: 10, contentOffsetEnd: 10, @@ -129,10 +116,8 @@ describe('scrollbar component', () => { test('getScrollbar returns child scrollbar when component is enabled' , () => { const scrollable = { - content: new paper.Path.Rectangle( - new paper.Point(0, 0), - new paper.Size(1000, 500) - ), + content: new paper.Path.Rectangle(new paper.Rectangle(0, 0, 1000, 500)), + container: new paper.Path.Rectangle(new paper.Rectangle(0, 0, 500, 500)), containerBounds: new paper.Rectangle(0, 0, 500, 500) }; const scrollbar = new ScrollbarComponent(scrollable, new paper.Point(0, 0)); @@ -143,10 +128,8 @@ describe('scrollbar component', () => { test('getScrollbar returns scrollbar that\'s not a child when component is disabled' , () => { const scrollable = { - content: new paper.Path.Rectangle( - new paper.Point(0, 0), - new paper.Size(500, 500) - ), + content: new paper.Path.Rectangle(new paper.Rectangle(0, 0, 500, 500)), + container: new paper.Path.Rectangle(new paper.Rectangle(0, 0, 500, 500)), containerBounds: new paper.Rectangle(0, 0, 500, 500) }; const scrollbar = new ScrollbarComponent(scrollable, new paper.Point(0, 0)); @@ -157,10 +140,8 @@ describe('scrollbar component', () => { test('getScrollbar allows changes to scrollbar attributes' , () => { const scrollable = { - content: new paper.Path.Rectangle( - new paper.Point(0, 0), - new paper.Size(500, 500) - ), + content: new paper.Path.Rectangle(new paper.Rectangle(0, 0, 500, 500)), + container: new paper.Path.Rectangle(new paper.Rectangle(0, 0, 500, 500)), containerBounds: new paper.Rectangle(0, 0, 500, 500) }; const scrollbar = new ScrollbarComponent(scrollable, new paper.Point(0, 0)); @@ -177,10 +158,8 @@ describe('scrollbar component', () => { test('setScrollbar allows fuller changes to scrollbar' , () => { const scrollable = { - content: new paper.Path.Rectangle( - new paper.Point(0, 0), - new paper.Size(500, 500) - ), + content: new paper.Path.Rectangle(new paper.Rectangle(0, 0, 500, 500)), + container: new paper.Path.Rectangle(new paper.Rectangle(0, 0, 500, 500)), containerBounds: new paper.Rectangle(0, 0, 500, 500) }; const scrollbar = new ScrollbarComponent(scrollable, new paper.Point(0, 0)); @@ -203,10 +182,8 @@ describe('scrollbar component', () => { test('getTrack returns child track when component is enabled' , () => { const scrollable = { - content: new paper.Path.Rectangle( - new paper.Point(0, 0), - new paper.Size(501, 500) - ), + content: new paper.Path.Rectangle(new paper.Rectangle(0, 0, 501, 500)), + container: new paper.Path.Rectangle(new paper.Rectangle(0, 0, 500, 500)), containerBounds: new paper.Rectangle(0, 0, 500, 500) }; const scrollbar = new ScrollbarComponent(scrollable, new paper.Point(0, 0)); @@ -217,10 +194,8 @@ describe('scrollbar component', () => { test('getTrack returns track that\'s not a child when component is disabled' , () => { const scrollable = { - content: new paper.Path.Rectangle( - new paper.Point(0, 0), - new paper.Size(500, 500) - ), + content: new paper.Path.Rectangle(new paper.Rectangle(0, 0, 500, 500)), + container: new paper.Path.Rectangle(new paper.Rectangle(0, 0, 500, 500)), containerBounds: new paper.Rectangle(0, 0, 500, 500) }; const scrollbar = new ScrollbarComponent(scrollable, new paper.Point(0, 0)); @@ -231,10 +206,8 @@ describe('scrollbar component', () => { test('getTrack allows changes to track attributes' , () => { const scrollable = { - content: new paper.Path.Rectangle( - new paper.Point(0, 0), - new paper.Size(500, 500) - ), + content: new paper.Path.Rectangle(new paper.Rectangle(0, 0, 500, 500)), + container: new paper.Path.Rectangle(new paper.Rectangle(0, 0, 500, 500)), containerBounds: new paper.Rectangle(0, 0, 500, 500) }; const scrollbar = new ScrollbarComponent(scrollable, new paper.Point(0, 0)); @@ -251,10 +224,8 @@ describe('scrollbar component', () => { test('setTrack allows fuller changes to track' , () => { const scrollable = { - content: new paper.Path.Rectangle( - new paper.Point(0, 0), - new paper.Size(500, 500) - ), + content: new paper.Path.Rectangle(new paper.Rectangle(0, 0, 500, 500)), + container: new paper.Path.Rectangle(new paper.Rectangle(0, 0, 500, 500)), containerBounds: new paper.Rectangle(0, 0, 500, 500) }; const scrollbar = new ScrollbarComponent(scrollable, new paper.Point(0, 0)); diff --git a/src/components/scrollbar.ts b/src/components/scrollbar.ts index 0a665c8..4d53447 100644 --- a/src/components/scrollbar.ts +++ b/src/components/scrollbar.ts @@ -10,6 +10,9 @@ const ACTIVE_SCROLLBAR_OPACITY = 1; const HIT_TEST_TOLERANCE = 11; const DEFAULT_SCROLLABLE_OFFSET = 0; +/** + * Enumeration of scrollbar directions. + */ type ScrollbarDirection = 'horizontal' | 'vertical'; /** @@ -21,8 +24,12 @@ interface Scrollable { */ content: paper.Group | paper.Item; /** - * @property containerBounds Bounds of the container that displays the viewable content. Could be something like - * the view or a clip mask. + * @property container The container that displays the viewable content. Could be something like the view or a + * clip mask. Used for hover event listening. HTMLCanvasElement used to solve issues with multiple paper views. + */ + container: paper.Group | paper.Item | paper.View | HTMLCanvasElement; + /** + * @property containerBounds Bounds of the container that displays the viewable content. Used for measurements. */ containerBounds: paper.Rectangle; /** @@ -56,7 +63,13 @@ class CustomEffects { */ export class ScrollbarComponent extends paper.Group { + // checks if any scrollbars are currently receiving mouse drag input to override other default events static anyIsDragging: boolean = false; + // the default scrollbar that can still receive input events while a scrollbar of another direction is active. For + // example, the main view's horizontal scrollbar can scroll from horizontal input events even if a vapp vertical + // scrollbar is active. + static _defaultScrollbar: ScrollbarComponent | undefined; + static defaultScrollbarDirection: ScrollbarDirection | undefined; protected scrollbar: paper.Path; protected track: paper.Path; protected scrollAmount: number; @@ -70,9 +83,7 @@ export class ScrollbarComponent extends paper.Group { // content's original position. used to constrain content position while scrolling private contentInitialPosition: number; // scrollbar is visible and interactive when container is hovered - private isActivatable: boolean = false; - // scrollbar is enabled when content does not fit container - private _isEnabled: boolean = true; + private enabled: boolean = true; private containerSize: number; // make a bigger area to make the track easier to interact with private extendedTrackArea: paper.Path.Rectangle; @@ -81,7 +92,8 @@ export class ScrollbarComponent extends paper.Group { private arrowKeyDown: paper.Tool; private scrollbarDrag: paper.Tool; private dragging: boolean = false; - private hovering: boolean = false; + private scrollbarHovering: boolean = false; + private containerHovering: boolean = false; private activeScrollTimeout: ReturnType; /** @@ -97,6 +109,7 @@ export class ScrollbarComponent extends paper.Group { * // Create a horizontal scrollbar with mostly default configuration. * const scrollable = { * content: (your content), + * container: (content's container), * containerBounds: (content's container.bounds) * } * const scrollbar = new ScrollbarComponent(scrollable, new paper.Point(30, 30)); @@ -106,6 +119,7 @@ export class ScrollbarComponent extends paper.Group { * const containerPadding = 20; * const scrollable = { * content: (your content), + * container: (content's container), * containerBounds: (content's container.bounds), * contentOffsetEnd: containerPadding * }; @@ -121,7 +135,6 @@ export class ScrollbarComponent extends paper.Group { readonly scrollTrackLength: number = 0, private direction: ScrollbarDirection = 'horizontal') { super(); - this.applyMatrix = false; this.position = this._point as paper.Point; this.pivot = new paper.Point(0, 0); @@ -138,11 +151,10 @@ export class ScrollbarComponent extends paper.Group { this.track = this.createTrack(this.scrollTrackLength, DEFAULT_SCROLLBAR_THICKNESS); this.scrollbar = this.createScrollbar(this.getProportionalLength(), DEFAULT_SCROLLBAR_THICKNESS); - // extend track and scrollbar hit areas based on hit tolerance to make interaction easier. used for - // onClick event and hover tests + // extend track and scrollbar hit areas based on hit tolerance to make interaction easier. used for onClick + // event and hover tests this.extendedTrackArea = this.extendHitArea(this.track); this.extendedScrollbarArea = this.extendHitArea(this.scrollbar); - // check if scrollbar is necessary if (this.scrollableContentFitsContainer()) { this.disable(); @@ -160,23 +172,38 @@ export class ScrollbarComponent extends paper.Group { // not visible until container is hovered and no other scrollbars are currently in dragging state this.visible = false; + // handles hover events for html canvas and paper items + if (this.scrollable.container instanceof HTMLCanvasElement) { + this.scrollable.container.onmouseenter = () => this.containerMouseEnter(); + this.scrollable.container.onmouseleave = () => this.containerMouseLeave(); + } else { + this.scrollable.container.onMouseEnter = this.containerMouseEnter; + this.scrollable.container.onMouseLeave = this.containerMouseLeave; + } } /** - * Gets isEnabled state. The scrollbar is enabled when content does not fit the container. + * Sets the default scrollbar. + * @param value The scrollbar that will be set as default. */ - get isEnabled(): boolean { - return this._isEnabled; + static set defaultScrollbar(value: ScrollbarComponent) { + this._defaultScrollbar = value; + this.defaultScrollbarDirection = this._defaultScrollbar.isHorizontal ? 'horizontal' : 'vertical'; } /** * Handler for container mouse enter event. */ containerMouseEnter = (): void => { + this.containerHovering = true; if (!ScrollbarComponent.anyIsDragging) { - this.isActivatable = true; + this.enabled = true; this.visible = true; this.activateDefaultTool(); + // handle scroll listening from the HTML canvas element. paper doesn't have a scroll event handler + this.project.view.element.onwheel = (event: WheelEvent) => { + this.onScroll(event); + }; } } @@ -184,9 +211,17 @@ export class ScrollbarComponent extends paper.Group { * Handler for container mouse leave event. */ containerMouseLeave = (): void => { + this.containerHovering = false; if (!ScrollbarComponent.anyIsDragging) { - this.isActivatable = false; + this.enabled = false; this.visible = false; + if (ScrollbarComponent._defaultScrollbar) { + this.resetDefaultTool(); + // reset default scroll listening from the HTML canvas element. paper doesn't have a scroll event handler + this.project.view.element.onwheel = (event: WheelEvent) => { + ScrollbarComponent._defaultScrollbar!.onScroll(event); + }; + } } } @@ -194,35 +229,27 @@ export class ScrollbarComponent extends paper.Group { * Activate the default tool. Used when the scrollable container is hovered or active. */ activateDefaultTool(): void { - if (this.isActivatable) { + if (this.enabled) { this.arrowKeyDown.activate(); } } /** * Handler for the wheel event. - * PaperJS does not have a scroll event handler, so this is set up externally where the HTML canvas element can be - * accessed. - * @param event WheelEvent passed from the HTML canvas. - * - * @example - * const scrollbar = new ScrollbarComponent(...); - * const canvas = this.demo.canvas.nativeElement; - * canvas.onwheel = (event: WheelEvent) => { - * scrollbar.onScroll(event); - * }; + * @param event PaperJS does not have a scroll event handler, so this is the WheelEvent passed from the HTML canvas. */ onScroll(event: WheelEvent): void { - if (this.isActivatable) { - const validScrollDirection = this.isHorizontal ? event.deltaX !== 0 : event.deltaY !== 0; - if (!validScrollDirection) { - return; + if (this.enabled) { + const scrollDirection = Math.abs(event.deltaX) > Math.abs(event.deltaY) ? 'horizontal' : 'vertical'; + if (scrollDirection === this.direction) { + event.preventDefault(); + this.setActiveContinuously(); + this.isHorizontal + ? this.changeScrollAndContentPosition(this.scrollbar.position.x + event.deltaX) + : this.changeScrollAndContentPosition(this.scrollbar.position.y + event.deltaY); + } else if (scrollDirection === ScrollbarComponent.defaultScrollbarDirection) { + ScrollbarComponent._defaultScrollbar!.onScroll(event); } - event.preventDefault(); - this.setActiveContinuously(); - this.isHorizontal - ? this.changeScrollAndContentPosition(this.scrollbar.position.x + event.deltaX) - : this.changeScrollAndContentPosition(this.scrollbar.position.y + event.deltaY); } } @@ -324,31 +351,41 @@ export class ScrollbarComponent extends paper.Group { } /** - * Enable scrollbar visibility and interactivity. + * Reset current paper tool to the default scrollbar's default paper tool when another scrollbar is no longer + * active and hovered. + */ + private resetDefaultTool() { + if (ScrollbarComponent._defaultScrollbar) { + ScrollbarComponent._defaultScrollbar!.activateDefaultTool(); + } + } + + /** + * Enable scrollbar component elements and interactivity. */ private enable(): void { - this._isEnabled = true; - this.addChildren([this.track, this.scrollbar, this.extendedTrackArea]); + this.enabled = true; + this.addChildren([this.track, this.extendedScrollbarArea, this.extendedTrackArea]); } /** * Disable scrollbar component elements and interactivity. */ private disable(): void { - this.isActivatable = false; - this._isEnabled = false; + this.enabled = false; this.removeChildren(); } /** - * Assign scrollable defaults for the optional properties. + * Assign scrollable defaults for the optional properties, so that none will be undefined. */ private assignScrollableDefaults(): Scrollable { - return Object.assign({ + return { contentOffsetStart: DEFAULT_SCROLLABLE_OFFSET, contentOffsetEnd: DEFAULT_SCROLLABLE_OFFSET, - checkFitWithOffsets: true - }, this.scrollable); + checkFitWithOffsets: true, + ...this.scrollable + }; } /** @@ -362,10 +399,10 @@ export class ScrollbarComponent extends paper.Group { const contentWithOffset = new paper.Path.Rectangle({ rectangle: content.bounds }); if (this.isHorizontal) { contentWithOffset.bounds.width = this.contentSizeWithOffsets(); - contentWithOffset.position.x -= (contentOffsetStart || DEFAULT_SCROLLABLE_OFFSET); + contentWithOffset.position.x -= contentOffsetStart!; } else { contentWithOffset.bounds.height = this.contentSizeWithOffsets(); - contentWithOffset.position.y -= (contentOffsetStart || DEFAULT_SCROLLABLE_OFFSET); + contentWithOffset.position.y -= contentOffsetStart!; } return contentWithOffset.isInside(containerBounds); } @@ -375,8 +412,7 @@ export class ScrollbarComponent extends paper.Group { */ private contentSizeWithOffsets(): number { const { content, contentOffsetStart, contentOffsetEnd } = this.scrollable; - return (this.isHorizontal ? content.bounds.width : content.bounds.height) - + (contentOffsetStart || DEFAULT_SCROLLABLE_OFFSET) + (contentOffsetEnd || DEFAULT_SCROLLABLE_OFFSET); + return (this.isHorizontal ? content.bounds.width : content.bounds.height) + contentOffsetStart! + contentOffsetEnd!; } /** @@ -423,8 +459,8 @@ export class ScrollbarComponent extends paper.Group { * The proportionate length for the scrollbar. Based on viewable content size divided by the full content size. */ private getProportionalLength(): number { - const fullSize = this.contentSizeWithOffsets() + (this.scrollable.contentOffsetEnd || 0) - - (this.scrollable.contentOffsetStart || 0); + const fullSize = this.contentSizeWithOffsets() + this.scrollable.contentOffsetEnd! + - this.scrollable.contentOffsetStart!; return this.containerSize / fullSize * this.scrollTrackLength; } @@ -458,7 +494,7 @@ export class ScrollbarComponent extends paper.Group { * @param event {paper.MouseEvent} */ private mouseLeave(event: paper.MouseEvent): void { - this.hovering = false; + this.scrollbarHovering = false; if (!this.dragging) { this.project.view.element.style.cursor = 'default'; this.setNormal(); @@ -469,7 +505,7 @@ export class ScrollbarComponent extends paper.Group { * Handler for mouse enter event. */ private mouseEnter(): void { - this.hovering = true; + this.scrollbarHovering = true; if (!this.dragging) { this.project.view.element.style.cursor = 'pointer'; } @@ -549,10 +585,9 @@ export class ScrollbarComponent extends paper.Group { private changeContentPosition(): void { const { content, contentOffsetStart, contentOffsetEnd } = this.scrollable; const contentMaxPosition = this.containerSize - (content.bounds[this.dimension] as number); - const contentDistance = contentMaxPosition - (contentOffsetStart || DEFAULT_SCROLLABLE_OFFSET) - - (contentOffsetEnd || DEFAULT_SCROLLABLE_OFFSET) * 2; + const contentDistance = contentMaxPosition - contentOffsetStart! - contentOffsetEnd! * 2; (content.position[this.axis] as number) = (this.scrollAmount * contentDistance) + this.contentInitialPosition - + (contentOffsetStart || DEFAULT_SCROLLABLE_OFFSET); + + contentOffsetStart!; } /** @@ -570,13 +605,12 @@ export class ScrollbarComponent extends paper.Group { tool.onMouseUp = (event: paper.ToolEvent) => { this.dragging = false; ScrollbarComponent.anyIsDragging = false; - this.project.view.element.style.cursor = this.hovering ? 'pointer' : 'default'; - // set visibility based on if content is holding the current point - if (!this.scrollable.containerBounds.contains(event.point)) { + this.project.view.element.style.cursor = this.scrollbarHovering ? 'pointer' : 'default'; + if (!this.containerHovering) { this.visible = false; - // TODO: less kludgey way of activating default tool. it's the last one created in the demo, but is a very - // temp fix. more tools can be added in the future, so this index won't always be correct - paper.tools[paper.tools.length - 1].activate(); + this.resetDefaultTool(); + this.setNormal(); + } else if (!this.scrollbarHovering) { this.setNormal(); } }; @@ -612,11 +646,12 @@ export class ScrollbarComponent extends paper.Group { * @param event {paper.KeyEvent} */ private moveByKeyDown(event: paper.KeyEvent) { - const validKeyPress = this.isHorizontal - ? event.key === 'left' || event.key === 'right' - : event.key === 'up' || event.key === 'down'; - if (validKeyPress) { - const movementAmount = Math.floor(this.getProportionalLength()); + const horizontalKeys = event.key === 'left' || event.key === 'right'; + const verticalKeys = event.key === 'up' || event.key === 'down'; + const keyPressDirection = (horizontalKeys && 'horizontal') || (verticalKeys && 'vertical'); + + if (keyPressDirection === this.direction) { + const movementAmount = Math.floor(this.getProportionalLength()) / 3; event.preventDefault(); this.setActiveContinuously(); switch (event.key) { @@ -633,6 +668,8 @@ export class ScrollbarComponent extends paper.Group { this.changeScrollAndContentPosition(this.scrollbar.position.x + movementAmount); break; } + } else if (keyPressDirection === ScrollbarComponent.defaultScrollbarDirection) { + ScrollbarComponent._defaultScrollbar!.moveByKeyDown(event); } } diff --git a/src/components/small-connector.ts b/src/components/small-connector.ts deleted file mode 100644 index 62168b0..0000000 --- a/src/components/small-connector.ts +++ /dev/null @@ -1,35 +0,0 @@ -import * as paper from 'paper'; -import { LIGHT_GREY } from '../constants/colors'; -import { SMALL_CONNECTOR_SIZE } from '../constants/dimensions'; - -/** - * Small Connector Visual Component. - */ -export class SmallConnectorComponent extends paper.Group { - - // the small filled circle - readonly _connector: paper.Path.Circle; - - /** - * Creates a new VappEdgeLabelComponent instance. - * - * @param _point the location that the vapp edge label should be rendered at - */ - constructor(private _point: paper.Point = new paper.Point(0, 0)) { - super(); - this.applyMatrix = false; - this.position = _point; - this.pivot = new paper.Point(0, 0); - - this._connector = new paper.Path.Circle({ - position: new paper.Point(0, 0), - radius: SMALL_CONNECTOR_SIZE / 2, - fillColor: LIGHT_GREY, - parent: this - }); - } - - get connector(): paper.Path.Circle { - return this._connector; - } -} diff --git a/src/components/vapp-edge-label.test.ts b/src/components/vapp-edge-label.test.ts index c3dae2c..446227c 100644 --- a/src/components/vapp-edge-label.test.ts +++ b/src/components/vapp-edge-label.test.ts @@ -6,6 +6,7 @@ describe('vapp edge label component', () => { beforeAll(() => { const canvasEl = document.createElement('canvas'); paper.setup(canvasEl); + paper.settings.applyMatrix = false; }); test('basic properties', () => { diff --git a/src/components/vapp-edge-label.ts b/src/components/vapp-edge-label.ts index 0d3071f..369394e 100644 --- a/src/components/vapp-edge-label.ts +++ b/src/components/vapp-edge-label.ts @@ -1,6 +1,5 @@ import * as paper from 'paper'; import { EntityLabelComponent } from './entity-label'; -// import { VappNetworkData } from './vapp-network'; import { VAPP_BACKGROUND_COLOR, LIGHT_GREY } from '../constants/colors'; import { CONNECTOR_RADIUS, DEFAULT_STROKE_WIDTH, LABEL_HEIGHT, CONNECTOR_MARGIN } from '../constants/dimensions'; import { DEFAULT_STROKE_STYLE } from '../constants/styles'; @@ -17,18 +16,17 @@ export class VappEdgeLabelComponent extends paper.Group { /** * Creates a new VappEdgeLabelComponent instance. * - * @param vappEdge the vappEdge data - * @param _point the location that the component will be rendered at. + * @param _text the name of the vApp edge's parent network + * @param _point the location that the component will be rendered at */ constructor(private _text: string, private _point: paper.Point = new paper.Point(0, 0)) { super(); - this.applyMatrix = false; this.pivot = new paper.Point(0, 0); this.position = _point; this._label = new EntityLabelComponent(this._text, ICON_COLOR); - this._label.getBackgroundComponent().style = { + this._label.background.style = { ...DEFAULT_STROKE_STYLE, fillColor: VAPP_BACKGROUND_COLOR }; diff --git a/src/components/vapp-network-list.test.ts b/src/components/vapp-network-list.test.ts index 74c342a..c3f14de 100644 --- a/src/components/vapp-network-list.test.ts +++ b/src/components/vapp-network-list.test.ts @@ -7,6 +7,7 @@ describe('vapp component', () => { beforeAll(() => { const canvasEl = document.createElement('canvas'); paper.setup(canvasEl); + paper.settings.applyMatrix = false; }); test('basic properties', () => { diff --git a/src/components/vapp-network-list.ts b/src/components/vapp-network-list.ts index 941bb03..c762e88 100644 --- a/src/components/vapp-network-list.ts +++ b/src/components/vapp-network-list.ts @@ -1,7 +1,6 @@ import * as paper from 'paper'; import { VappNetworkData, VappNetworkComponent } from './vapp-network'; import { LowestVnicPointByNetworkName } from './vm-and-vnic-list'; -import { DEFAULT_STROKE_STYLE } from '../constants/styles'; import { VAPP_NETWORK_RIGHT_MARGIN } from '../constants/dimensions'; /** @@ -31,7 +30,6 @@ export class VappNetworkListComponent extends paper.Group { constructor(private _vappNetworks: VappNetworkData[], private _point: paper.Point = new paper.Point(0, 0)) { super(); - this.applyMatrix = false; this.pivot = new paper.Point(0, 0); this.position = this._point; @@ -57,6 +55,14 @@ export class VappNetworkListComponent extends paper.Group { }); } + /** + * Gets the position of the last vApp network (furthest right). + */ + get lastNetworkPosition(): paper.Point { + const listCount = this.networkPathList.length; + return listCount ? this.networkPathList[listCount - 1].position : new paper.Point(0, 0); + } + /** * Gets the vApp Network data. */ @@ -95,9 +101,8 @@ export class VappNetworkListComponent extends paper.Group { * Clones the bottom segment of vapp networks to separate for scrolling and removes the original segment. * @param splitPositionY vertical point where the network path should be split for cloning and separation */ - cloneVmListSegments(splitPositionY: number) { + cloneVmListSegments(splitPositionY: number): paper.Group { const clones = new paper.Group(); - clones.applyMatrix = false; clones.pivot = new paper.Point(0, 0); clones.position = this._point; this.networkPathList.forEach(network => { diff --git a/src/components/vapp-network.test.ts b/src/components/vapp-network.test.ts index abb3ee2..1c5c104 100644 --- a/src/components/vapp-network.test.ts +++ b/src/components/vapp-network.test.ts @@ -7,6 +7,7 @@ describe('vapp component', () => { beforeAll(() => { const canvasEl = document.createElement('canvas'); paper.setup(canvasEl); + paper.settings.applyMatrix = false; }); test('basic properties', () => { @@ -83,7 +84,7 @@ describe('vapp component', () => { }; const network = new VappNetworkComponent(vappNetworkData); network.setDisconnected(); - expect(network.children.length).toBe(2); // path and small connector component + expect(network.children.length).toBe(2); // path and bullet point connection icon component expect(network.path.bounds.bottom).toBe(VAPP_PADDING + LABEL_HEIGHT / 2); }); diff --git a/src/components/vapp-network.ts b/src/components/vapp-network.ts index f9b7193..9d058ef 100644 --- a/src/components/vapp-network.ts +++ b/src/components/vapp-network.ts @@ -2,8 +2,8 @@ import * as paper from 'paper'; import { CANVAS_BACKGROUND_COLOR } from '../constants/colors'; import { IsolatedNetworkLabelComponent } from './isolated-network-label'; import { VappEdgeLabelComponent } from './vapp-edge-label'; -import { ConnectorComponent } from './connector'; -import { SmallConnectorComponent } from './small-connector'; +import { ConnectionIconComponent } from './connection-icon'; +import { BulletPointConnectionIconComponent } from './bullet-point-connection-icon'; import { CONNECTOR_RADIUS, CONNECTOR_MARGIN, VAPP_PADDING, LABEL_HEIGHT, DEFAULT_STROKE_WIDTH, LABEL_BOTTOM_PADDING } from '../constants/dimensions'; import { DEFAULT_STROKE_STYLE } from '../constants/styles'; @@ -11,6 +11,9 @@ import { DEFAULT_STROKE_STYLE } from '../constants/styles'; const MULTIPLE_ISOLATED_NETWORK_PADDING = 13; const ISOLATED_NETWORK_PADDING = 5; +/** + * Enumeration of vApp fence modes. + */ export type FenceMode = 'BRIDGED' | 'NAT_ROUTED' | 'ISOLATED'; /** @@ -30,7 +33,7 @@ export class VappNetworkComponent extends paper.Group { readonly _path: paper.Path.Line; // connection icon or isolated network label at the top of the network path - private connectionComponent: ConnectorComponent | IsolatedNetworkLabelComponent; + private connectionComponent: ConnectionIconComponent | IsolatedNetworkLabelComponent; readonly edgeLabel: VappEdgeLabelComponent; readonly isNatRouted: boolean = false; readonly isIsolated: boolean = false; @@ -52,7 +55,6 @@ export class VappNetworkComponent extends paper.Group { private edgeNetworkCount: number = 0, private topmostPointY: number = 59) { super(); - this.applyMatrix = false; this.pivot = new paper.Point(0, 0); this.position = this._point; @@ -105,7 +107,7 @@ export class VappNetworkComponent extends paper.Group { // add the connection component based on network's fence mode this.connectionComponent = this.isIsolated ? new IsolatedNetworkLabelComponent(this._vappNetwork.name, topmostPoint) - : new ConnectorComponent(topmostPoint, CANVAS_BACKGROUND_COLOR); + : new ConnectionIconComponent(topmostPoint, CANVAS_BACKGROUND_COLOR); this.addChild(this.connectionComponent); } @@ -125,7 +127,7 @@ export class VappNetworkComponent extends paper.Group { if (!this.isNatRouted) { // add the bottommost point and disconnected icon next to the vapp label this._path.add(new paper.Point(0, VAPP_PADDING + LABEL_HEIGHT / 2)); - this.addChild(new SmallConnectorComponent(this._path.bounds.bottomCenter)); + this.addChild(new BulletPointConnectionIconComponent(this._path.bounds.bottomCenter)); } else { // add the bottommost point at the edge label's position this.path.add(new paper.Point(0, this.edgeLabel.position.y)); diff --git a/src/components/vapp.ts b/src/components/vapp.ts index 9c1f8af..b05ea02 100644 --- a/src/components/vapp.ts +++ b/src/components/vapp.ts @@ -13,8 +13,8 @@ import { ScrollbarComponent } from './scrollbar'; const MARGIN_RIGHT = 30; const BACKGROUND_RADIUS = 5; const LABEL_ICON_COLOR = '#CA67B8'; -const VAPP_LABEL_BOTTOM_MARGIN = 10; -const EDGE_LABEL_BOTTOM_MARGIN = 5; +const VAPP_LABEL_BOTTOM_MARGIN = 20; +const EDGE_LABEL_BOTTOM_MARGIN = 15; /** * Interface for vApp data. @@ -31,13 +31,13 @@ export interface VappData { */ export class VappComponent extends paper.Group { - readonly label: EntityLabelComponent; - readonly background: paper.Path.Rectangle; - readonly _margin: MarginComponent; - readonly vms: VmAndVnicListComponent; - readonly vappNetworks: VappNetworkListComponent; + private label: EntityLabelComponent; + private background: paper.Path.Rectangle; + private _margin: MarginComponent; + private vms: VmAndVnicListComponent; + private vappNetworks: VappNetworkListComponent; // position for the division between any labels and vm/vnic list - readonly divisionPositionY: number; + private divisionPositionY: number; private scrollbar: ScrollbarComponent; // content needs scrollbar private _isScrollable: boolean = false; @@ -51,7 +51,6 @@ export class VappComponent extends paper.Group { constructor(private _vapp: VappData, private _point: paper.Point = new paper.Point(0, 0)) { super(); - this.applyMatrix = false; this.pivot = new paper.Point(0, 0); this.position = _point; @@ -69,7 +68,7 @@ export class VappComponent extends paper.Group { this._vapp.name, LABEL_ICON_COLOR, new paper.Point(labelPositionX, VAPP_PADDING), - 'bold' + { fontWeight: 'bold' } ); this.addChild(this.label); @@ -98,6 +97,7 @@ export class VappComponent extends paper.Group { this.vms = new VmAndVnicListComponent( this._vapp.vms, this.vappNetworks && this.vappNetworks.networkPositionsByName, + this.vappNetworks && this.vappNetworks.lastNetworkPosition, new paper.Point(VAPP_PADDING + DEFAULT_STROKE_WIDTH, this.divisionPositionY)); this.addChild(this.vms); @@ -133,8 +133,6 @@ export class VappComponent extends paper.Group { // set up scrolling if necessary if (this._isScrollable) { this.clipAndScrollVmList(); - this.onMouseEnter = this.scrollMouseEnter; - this.onMouseLeave = this.scrollMouseLeave; } // margin - used by other vapps for static or dynamic positioning @@ -156,50 +154,10 @@ export class VappComponent extends paper.Group { return this._margin; } - /** - * Gets the isScrollable state when the vApp needs a scrollbar. - */ - get isScrollable(): boolean { - return this._isScrollable; - } - - /** - * Sets scroll listening if there's a scrollbar. - * @param event WheelEvent passed from the native HTML canvas. - */ - // TODO: add scroll listener with a better method. it's in parent demo component for now. could create a native canvas - // event observable service similar to the paper.event service? - setScrollListening(event: WheelEvent) { - if (this.scrollbar) { - this.scrollbar.onScroll(event); - } - } - - /** - * Handler for the mouse enter event when there is a scrollbar. - */ - private scrollMouseEnter = (): void => { - this.scrollbar.containerMouseEnter(); - } - - /** - * Handler for the mouse leave event when there is a scrollbar. - */ - private scrollMouseLeave = (): void => { - if (!ScrollbarComponent.anyIsDragging) { - this.scrollbar.containerMouseLeave(); - // TODO: less kludgey way of activating the global default paper tool. it's the last one created in the demo. more - // tools can be added or created in the future, so this index won't always be correct. can create a tool - // service and/or tool stack which creates and destroys paper.tools when items are in or out of the view - // activates the global default tool (view horizontal scrolling in the parent demo component) - paper.tools[paper.tools.length - 1].activate(); - } - } - /** * Clips and adds scrolling to the VmAndVnicList component when it's too large for the view. */ - private clipAndScrollVmList() { + private clipAndScrollVmList(): void { // create drop shadow at the top of the vm list that fades in or out onScroll const dropShadow = new paper.Path.Rectangle({ point: new paper.Point(0, 0), @@ -207,8 +165,8 @@ export class VappComponent extends paper.Group { opacity: 0, style: { fillColor: VAPP_BACKGROUND_COLOR, - shadowColor: new paper.Color(0, 0, 0, 0.41), - shadowBlur: 10, + shadowColor: new paper.Color(0, 0, 0, 0.25), + shadowBlur: 5, shadowOffset: new paper.Point(0, 2) } }); @@ -223,14 +181,22 @@ export class VappComponent extends paper.Group { // items that will be scrollable const scrollableContent = new paper.Group({ - applyMatrix: false, children: [vappNetworkClone, this.vms] }); + // apply clip mask + // tslint:disable-next-line + new paper.Group({ + children: [vmListClipMask, scrollableContent, dropShadow], + clipped: true, + parent: this + }); + // scrollbar set up const scrollbarPadding = 5; this.scrollbar = new ScrollbarComponent({ content: scrollableContent, + container: this, containerBounds: vmListClipMask.bounds, contentOffsetEnd: VAPP_PADDING / 2 }, @@ -239,6 +205,7 @@ export class VappComponent extends paper.Group { vmListClipMask.bounds.height - VAPP_PADDING, 'vertical' ); + this.addChild(this.scrollbar); // drop shadow fades in or out onScroll this.scrollbar.setCustomEffects({ setActive: function() { @@ -250,14 +217,5 @@ export class VappComponent extends paper.Group { }, 150); } }); - - // apply clip mask - // tslint:disable-next-line - new paper.Group({ - applyMatrix: false, - children: [vmListClipMask, scrollableContent, this.scrollbar, dropShadow], - clipped: true, - parent: this - }); } } diff --git a/src/components/vm-and-vnic-list.test.ts b/src/components/vm-and-vnic-list.test.ts index c4b248c..6d2dd38 100644 --- a/src/components/vm-and-vnic-list.test.ts +++ b/src/components/vm-and-vnic-list.test.ts @@ -2,7 +2,7 @@ import { VmAndVnicListComponent, LowestVnicPointByNetworkName } from './vm-and-v import { VappNetworkPositionsByName } from './vapp-network-list'; import { VmData } from './vm'; import * as paper from 'paper'; -import { LABEL_HEIGHT, VM_MARGIN_VERTICAL } from '../constants/dimensions'; +import { CONNECTOR_RADIUS, DEFAULT_STROKE_WIDTH, LABEL_HEIGHT, VM_MARGIN_VERTICAL } from '../constants/dimensions'; describe('vapp component', () => { @@ -15,6 +15,7 @@ describe('vapp component', () => { (window as any).XMLHttpRequest = jest.fn().mockImplementation(xhrMockClass); const canvasEl = document.createElement('canvas'); paper.setup(canvasEl); + paper.settings.applyMatrix = false; }); function getExpectedVnicPoints(vmData: VmData[], @@ -28,8 +29,9 @@ describe('vapp component', () => { expectedVnicPoints[vnic.network_name].y += LABEL_HEIGHT + VM_MARGIN_VERTICAL; } else { expectedVnicPoints[vnic.network_name] = - new paper.Point(networkPositions[vnic.network_name].x + position.x + 4.5, // 4.5 VAPP_NETWORK_PADDING_RIGHT - networkCount * (LABEL_HEIGHT + VM_MARGIN_VERTICAL) + LABEL_HEIGHT / 2 + VM_MARGIN_VERTICAL + position.y); + new paper.Point( + networkPositions[vnic.network_name].x + position.x + CONNECTOR_RADIUS - DEFAULT_STROKE_WIDTH, + networkCount * (LABEL_HEIGHT + VM_MARGIN_VERTICAL) + LABEL_HEIGHT / 2 + position.y); networkCount++; } }); @@ -37,7 +39,7 @@ describe('vapp component', () => { return expectedVnicPoints; } - test.only('basic properties and vnics on different networks', () => { + test('basic properties and vnics on different networks', () => { const vmsData: VmData[] = [ { uuid: '', @@ -93,10 +95,11 @@ describe('vapp component', () => { expect(vms.data[i].vnics).toBe(data.vnics); }); expect(vms.lowestVnicPointByNetworkName).toEqual(getExpectedVnicPoints(vmsData, networkPositions, position)); + expect(vms.position.x).toBe(position.x); expect(vms.position.y).toBe(position.y); }); - test.only('multiple vnics on same network', () => { + test('multiple vnics on same network', () => { const vmsData: VmData[] = [ { uuid: '', @@ -142,13 +145,13 @@ describe('vapp component', () => { A: new paper.Point(0, 30) }; const position = new paper.Point(20, 50); - const vms = new VmAndVnicListComponent(vmsData, networkPositions, position); + const vms = new VmAndVnicListComponent(vmsData, networkPositions, networkPositions['A'], position); expect(vms.lowestVnicPointByNetworkName).toEqual(getExpectedVnicPoints(vmsData, networkPositions, position)); expect(vms.position.x).toBe(position.x); expect(vms.position.y).toBe(position.y); }); - test.only('mix vnics on same network or different network', () => { + test('mix vnics on same network or different network', () => { const vmsData: VmData[] = [ { uuid: '', @@ -195,7 +198,7 @@ describe('vapp component', () => { B: new paper.Point(20, 30) }; const position = new paper.Point(60, 10); - const vms = new VmAndVnicListComponent(vmsData, networkPositions, position); + const vms = new VmAndVnicListComponent(vmsData, networkPositions, networkPositions['B'], position); expect(vms.lowestVnicPointByNetworkName).toEqual(getExpectedVnicPoints(vmsData, networkPositions, position)); expect(vms.position.x).toBe(position.x); expect(vms.position.y).toBe(position.y); diff --git a/src/components/vm-and-vnic-list.ts b/src/components/vm-and-vnic-list.ts index eb1a334..0fd8f52 100644 --- a/src/components/vm-and-vnic-list.ts +++ b/src/components/vm-and-vnic-list.ts @@ -1,12 +1,10 @@ import * as paper from 'paper'; import { VmData, VmComponent } from './vm'; import { VnicComponent } from './vnic'; -import { CONNECTOR_RADIUS, CONNECTOR_SIZE, CONNECTOR_RIGHT_PADDING, DEFAULT_STROKE_WIDTH, VAPP_NETWORK_RIGHT_MARGIN, - LABEL_HEIGHT, VM_MARGIN_VERTICAL } from '../constants/dimensions'; +import { CONNECTOR_RADIUS, CONNECTOR_SIZE, CONNECTOR_RIGHT_MARGIN, DEFAULT_STROKE_WIDTH, LABEL_HEIGHT, + VM_MARGIN_VERTICAL } from '../constants/dimensions'; import { VappNetworkPositionsByName } from './vapp-network-list'; -const VAPP_NETWORK_PADDING_RIGHT = 4.5; - /** * Interface for the lowest vnic point by network name. */ @@ -21,48 +19,53 @@ export class VmAndVnicListComponent extends paper.Group { // store lowest vnic point by network name for setting the lowest point of the vapp network path private _lowestVnicPointByNetworkName: LowestVnicPointByNetworkName = {}; - // x position of the last (furthest right) network in vapp network list used for positioning vms and vnics - readonly lastNetworkPositionX: number = 0; /** * Creates a new VmAndVnicListComponent instance. * * @param _vms the vms data - * @param vappNetworkPositionsByName the vapp network positions by name from the VappNetworkListComponent for + * @param vappNetworkPositionsByName the vapp network positions by name from the VappNetworkListComponent used for * positioning x value of matching vnics + * @param lastNetworkPosition the position of the last (furthest right) vapp network used for positioning vms and + * unattached vnics * @param _point the location that the vm and vnic list should be rendered at */ constructor(private _vms: Array, private vappNetworkPositionsByName: VappNetworkPositionsByName = {}, + private lastNetworkPosition: paper.Point = new paper.Point(0, 0), private _point: paper.Point = new paper.Point(0, 0)) { super(); - this.applyMatrix = false; this.pivot = new paper.Point(0, 0); this.position = _point; - // reusable calculations - const vappNetworkCount = Object.keys(this.vappNetworkPositionsByName).length; - this.lastNetworkPositionX = vappNetworkCount && (vappNetworkCount - 1) * VAPP_NETWORK_RIGHT_MARGIN - + CONNECTOR_RADIUS - DEFAULT_STROKE_WIDTH; - const vnicOffsetX = vappNetworkCount - ? CONNECTOR_SIZE + CONNECTOR_RIGHT_PADDING + DEFAULT_STROKE_WIDTH * 2 - : VAPP_NETWORK_PADDING_RIGHT; - const vnicOffsetY = LABEL_HEIGHT / 2 + VM_MARGIN_VERTICAL; - const vmOffsetX = CONNECTOR_RADIUS + CONNECTOR_RIGHT_PADDING + DEFAULT_STROKE_WIDTH * 2; + // reusable values + const hasVappNetworks = Object.keys(this.vappNetworkPositionsByName).length !== 0; + // middle of vm label + const vnicOffsetY = LABEL_HEIGHT / 2; + // offset from vnic pivot in the center to the left stroke bounds + const vnicOffsetX = CONNECTOR_RADIUS - DEFAULT_STROKE_WIDTH; // draw vms and their vnics this._vms.forEach((vmData, index) => { - const pointY = (LABEL_HEIGHT + VM_MARGIN_VERTICAL) * index; + const sharedPointY = (LABEL_HEIGHT + VM_MARGIN_VERTICAL) * index; + let xPositionMultiplier = hasVappNetworks ? 1 : 0; + vmData.vnics.forEach(vnicData => { + const matchingNetwork = this.vappNetworkPositionsByName[vnicData.network_name]; + const vnicPointX = matchingNetwork ? matchingNetwork.x : this.getPointX(xPositionMultiplier); const vnic = new VnicComponent( vnicData, - new paper.Point(this.getVnicPointX(vnicData.network_name, vnicOffsetX), pointY + vnicOffsetY)); + new paper.Point(vnicPointX + vnicOffsetX, sharedPointY + vnicOffsetY)); this.addChild(vnic); this._lowestVnicPointByNetworkName[vnicData.network_name] = this.localToGlobal(vnic.position); + if (!matchingNetwork) { + xPositionMultiplier++; + } }); + const vm = new VmComponent( vmData, - new paper.Point(this.getVmPointX(vmOffsetX),pointY + VM_MARGIN_VERTICAL), + new paper.Point(this.getPointX(xPositionMultiplier), sharedPointY), true); this.addChild(vm); }); @@ -83,31 +86,12 @@ export class VmAndVnicListComponent extends paper.Group { } /** - * Calculates VNIC's x position based on if it's attached or unattached to a network and items drawn to the left - * of it. - * @param name the name of the vApp network that the VNIC is attached to - * @param offsetX the extra offset added to x position value - */ - private getVnicPointX(name: string, offsetX: number): number { - const pathPosition = this.vappNetworkPositionsByName && this.vappNetworkPositionsByName[name]; - if (pathPosition) { - // position is based on matching vapp network path - return pathPosition.x + VAPP_NETWORK_PADDING_RIGHT; - } else { - // position is based on the item (vnic or vapp network path) that is located furthest to right - const lastChildPositionX = this.lastChild && this.lastChild.position.x; - return Math.max(lastChildPositionX, this.lastNetworkPositionX) + offsetX; - } - } - - /** - * Calculate's VM's x position based on the item (vnic or vapp network path) drawn to the left of it. - * @param offsetX the extra offset added to x position value + * Calculates x position for VMs and unattached VNICs. + * @param multiplier amount to stagger horizontal position by. */ - private getVmPointX(offsetX: number): number { - const lastVnicAndOffsetX = (this.lastChild instanceof VnicComponent) ? this.lastChild.position.x + offsetX : 0; - const lastNetworkAndOffsetX = this.lastNetworkPositionX && this.lastNetworkPositionX + offsetX; - // position is based on the item (vnic or vapp network path) that is located furthest to the right - return Math.max(lastVnicAndOffsetX, lastNetworkAndOffsetX); + private getPointX(multiplier: number): number { + // multiply by the size of the vnic icon including margin and stroke + return multiplier * (CONNECTOR_SIZE + CONNECTOR_RIGHT_MARGIN + DEFAULT_STROKE_WIDTH * 2) + + this.lastNetworkPosition.x; } } diff --git a/src/components/vm.test.ts b/src/components/vm.test.ts index e123847..da38dfc 100644 --- a/src/components/vm.test.ts +++ b/src/components/vm.test.ts @@ -1,7 +1,8 @@ import { VmComponent, VmData } from './vm'; import * as paper from 'paper'; import { LABEL_HORIZONTAL_PADDING, VM_ICON_SIZE } from '../constants/dimensions'; -import { FONT_SIZE, VERTICAL_PADDING_TOP } from './label'; +import { VERTICAL_PADDING_TOP } from './label'; +import { FONT_SIZE } from './label-text'; describe('vm component', () => { @@ -12,6 +13,7 @@ describe('vm component', () => { setRequestHeader: jest.fn() }); (window as any).XMLHttpRequest = jest.fn().mockImplementation(xhrMockClass); + paper.settings.applyMatrix = false; }); test('basic properties', () => { @@ -39,9 +41,9 @@ describe('vm component', () => { expect(vm.getVmData().vnics).toBe(vmData.vnics); expect(vm.position.x).toBe(position.x); expect(vm.position.y).toBe(position.y); - expect(vm.getLabelComponent().getTextComponent().position.x).toBe(LABEL_HORIZONTAL_PADDING + VM_ICON_SIZE); - expect(vm.getLabelComponent().getTextComponent().position.y).toBe(VERTICAL_PADDING_TOP + FONT_SIZE); - expect(vm.getLabelComponent().getTextComponent().content).toBe(vmData.name); + expect(vm.getLabelComponent().text.position.x).toBe(LABEL_HORIZONTAL_PADDING + VM_ICON_SIZE); + expect(vm.getLabelComponent().text.position.y).toBe(VERTICAL_PADDING_TOP + FONT_SIZE); + expect(vm.getLabelComponent().text.content).toBe(vmData.name); }); }); diff --git a/src/components/vm.ts b/src/components/vm.ts index 917ebcc..6ca357c 100644 --- a/src/components/vm.ts +++ b/src/components/vm.ts @@ -46,7 +46,6 @@ export class VmComponent extends paper.Group { visible: boolean = false) { super(); const self = this; - this.applyMatrix = false; this.position = _point; this.pivot = new paper.Point(0, 0); self._label = new IconLabelComponent(self._vm.name, diff --git a/src/components/vnic.test.ts b/src/components/vnic.test.ts index 414ef45..655bd7e 100644 --- a/src/components/vnic.test.ts +++ b/src/components/vnic.test.ts @@ -6,6 +6,7 @@ describe('vnic component', () => { beforeAll(() => { const canvasEl = document.createElement('canvas'); paper.setup(canvasEl); + paper.settings.applyMatrix = false; }); test('basic properties', () => { diff --git a/src/components/vnic.ts b/src/components/vnic.ts index 509aad5..c31ccf9 100644 --- a/src/components/vnic.ts +++ b/src/components/vnic.ts @@ -1,5 +1,5 @@ import * as paper from 'paper'; -import { ConnectorComponent } from './connector'; +import { ConnectionIconComponent } from './connection-icon'; import { VAPP_BACKGROUND_COLOR } from '../constants/colors'; import { DEFAULT_STROKE_STYLE } from '../constants/styles'; @@ -17,7 +17,7 @@ export interface VnicData { */ export class VnicComponent extends paper.Group { - readonly icon: ConnectorComponent; + readonly icon: ConnectionIconComponent; /** * Creates a new VnicComponent instance. @@ -27,10 +27,9 @@ export class VnicComponent extends paper.Group { constructor(private _vnic: VnicData, private _point: paper.Point = new paper.Point(0, 0)) { super(); - this.applyMatrix = false; this.position = _point; - this.icon = new ConnectorComponent(); + this.icon = new ConnectionIconComponent(); this.addChild(this.icon); // draw additional icon visual elements (slash and circle cut) if vnic is disconnected diff --git a/src/constants/colors.ts b/src/constants/colors.ts index 292a27e..1c23bcc 100644 --- a/src/constants/colors.ts +++ b/src/constants/colors.ts @@ -5,3 +5,4 @@ export const LIGHT_GREY = '#87A1B5'; export const VAPP_BACKGROUND_COLOR = '#343B4E'; export const CANVAS_BACKGROUND_COLOR = '#191C28'; +export const WHITE = '#FFFFFF'; diff --git a/src/constants/dimensions.ts b/src/constants/dimensions.ts index 112f319..9e0f925 100644 --- a/src/constants/dimensions.ts +++ b/src/constants/dimensions.ts @@ -13,7 +13,7 @@ export const VM_MARGIN_VERTICAL = 10; export const CONNECTOR_SIZE = 11; export const CONNECTOR_RADIUS = CONNECTOR_SIZE / 2; export const CONNECTOR_MARGIN = 10; -export const CONNECTOR_RIGHT_PADDING = 8; +export const CONNECTOR_RIGHT_MARGIN = 8; export const SMALL_CONNECTOR_SIZE = 7; export const DEFAULT_SCROLLBAR_THICKNESS = 5; export const VAPP_PADDING = 20; diff --git a/src/gibraltar.ts b/src/gibraltar.ts index 013cc82..3d279f6 100644 --- a/src/gibraltar.ts +++ b/src/gibraltar.ts @@ -19,6 +19,9 @@ export class Gibraltar { this.canvas = el as HTMLCanvasElement; } paper.setup(this.canvas); + // apply this setting globally to applicable PaperJS types, so child components behave relatively to their parent + // component. disable on an individual basis by setting the item's applyMatrix property to `true` + paper.settings.applyMatrix = false; } }