GUI2 Extract Topology view in to its own library

Change-Id: I45597d0902c99b5b3d606966866cc518011c54a0
diff --git a/web/gui2-topo-lib/projects/gui2-topo-lib/src/lib/layer/forcesvg/forcesvg.component.ts b/web/gui2-topo-lib/projects/gui2-topo-lib/src/lib/layer/forcesvg/forcesvg.component.ts
new file mode 100644
index 0000000..6910353
--- /dev/null
+++ b/web/gui2-topo-lib/projects/gui2-topo-lib/src/lib/layer/forcesvg/forcesvg.component.ts
@@ -0,0 +1,517 @@
+/*
+ * Copyright 2019-present Open Networking Foundation
+ *
+ * Licensed under the Apache License, Version 2.0 (the 'License');
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an 'AS IS' BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+import {
+    ChangeDetectionStrategy,
+    ChangeDetectorRef,
+    Component,
+    EventEmitter,
+    HostListener,
+    Input,
+    OnChanges,
+    OnInit,
+    Output,
+    QueryList,
+    SimpleChange,
+    SimpleChanges,
+    ViewChildren
+} from '@angular/core';
+import {
+    LocMeta,
+    LogService,
+    MetaUi,
+    WebSocketService,
+    ZoomUtils
+} from 'gui2-fw-lib';
+import {
+    Device,
+    ForceDirectedGraph,
+    Host,
+    HostLabelToggle,
+    LabelToggle,
+    LayerType,
+    Link,
+    LinkHighlight,
+    Location,
+    ModelEventMemo,
+    ModelEventType,
+    Region,
+    RegionLink,
+    SubRegion,
+    UiElement
+} from './models';
+import {LocationType} from '../backgroundsvg/backgroundsvg.component';
+import {DeviceNodeSvgComponent} from './visuals/devicenodesvg/devicenodesvg.component';
+import { HostNodeSvgComponent} from './visuals/hostnodesvg/hostnodesvg.component';
+import { LinkSvgComponent} from './visuals/linksvg/linksvg.component';
+
+interface UpdateMeta {
+    id: string;
+    class: string;
+    memento: MetaUi;
+}
+
+/**
+ * ONOS GUI -- Topology Forces Graph Layer View.
+ *
+ * The regionData is set by Topology Service on WebSocket topo2CurrentRegion callback
+ * This drives the whole Force graph
+ */
+@Component({
+    selector: '[onos-forcesvg]',
+    templateUrl: './forcesvg.component.html',
+    styleUrls: ['./forcesvg.component.css'],
+    changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class ForceSvgComponent implements OnInit, OnChanges {
+    @Input() deviceLabelToggle: LabelToggle.Enum = LabelToggle.Enum.NONE;
+    @Input() hostLabelToggle: HostLabelToggle.Enum = HostLabelToggle.Enum.NONE;
+    @Input() showHosts: boolean = false;
+    @Input() highlightPorts: boolean = true;
+    @Input() onosInstMastership: string = '';
+    @Input() visibleLayer: LayerType = LayerType.LAYER_DEFAULT;
+    @Input() selectedLink: RegionLink = null;
+    @Input() scale: number = 1;
+    @Input() regionData: Region = <Region>{devices: [ [], [], [] ], hosts: [ [], [], [] ], links: []};
+    @Output() linkSelected = new EventEmitter<RegionLink>();
+    @Output() selectedNodeEvent = new EventEmitter<UiElement>();
+    public graph: ForceDirectedGraph;
+    private _options: { width, height } = { width: 800, height: 600 };
+
+    // References to the children of this component - these are created in the
+    // template view with the *ngFor and we get them by a query here
+    @ViewChildren(DeviceNodeSvgComponent) devices: QueryList<DeviceNodeSvgComponent>;
+    @ViewChildren(HostNodeSvgComponent) hosts: QueryList<HostNodeSvgComponent>;
+    @ViewChildren(LinkSvgComponent) links: QueryList<LinkSvgComponent>;
+
+    constructor(
+        protected log: LogService,
+        private ref: ChangeDetectorRef,
+        protected wss: WebSocketService
+    ) {
+        this.selectedLink = null;
+        this.log.debug('ForceSvgComponent constructed');
+    }
+
+    /**
+     * Utility for extracting a node name from an endpoint string
+     * In some cases - have to remove the port number from the end of a device
+     * name
+     * @param endPtStr The end point name
+     */
+    private static extractNodeName(endPtStr: string): string {
+        const slash: number = endPtStr.indexOf('/');
+        if (slash === -1) {
+            return endPtStr;
+        } else {
+            const afterSlash = endPtStr.substr(slash + 1);
+            if (afterSlash === 'None') {
+                return endPtStr;
+            } else {
+                return endPtStr.substr(0, slash);
+            }
+        }
+    }
+
+    /**
+     * Recursive method to compare 2 objects attribute by attribute and update
+     * the first where a change is detected
+     * @param existingNode 1st object
+     * @param updatedNode 2nd object
+     */
+    private static updateObject(existingNode: Object, updatedNode: Object): number {
+        let changed: number = 0;
+        for (const key of Object.keys(updatedNode)) {
+            const o = updatedNode[key];
+            if (key === 'id') {
+                continue;
+            } else if (o && typeof o === 'object' && o.constructor === Object) {
+                changed += ForceSvgComponent.updateObject(existingNode[key], updatedNode[key]);
+            } else if (existingNode[key] !== updatedNode[key]) {
+                changed++;
+                existingNode[key] = updatedNode[key];
+            }
+        }
+        return changed;
+    }
+
+    @HostListener('window:resize', ['$event'])
+    onResize(event) {
+        this.graph.initSimulation(this.options);
+        this.log.debug('Simulation reinit after resize', event);
+    }
+
+    /**
+     * After the component is initialized create the Force simulation
+     * The list of devices, hosts and links will not have been receieved back
+     * from the WebSocket yet as this time - they will be updated later through
+     * ngOnChanges()
+     */
+    ngOnInit() {
+        // Receiving an initialized simulated graph from our custom d3 service
+        this.graph = new ForceDirectedGraph(this.options, this.log);
+
+        /** Binding change detection check on each tick
+         * This along with an onPush change detection strategy should enforce
+         * checking only when relevant! This improves scripting computation
+         * duration in a couple of tests I've made, consistently. Also, it makes
+         * sense to avoid unnecessary checks when we are dealing only with
+         * simulations data binding.
+         */
+        this.graph.ticker.subscribe((simulation) => {
+            // this.log.debug("Force simulation has ticked", simulation);
+            this.ref.markForCheck();
+        });
+        this.log.debug('ForceSvgComponent initialized - waiting for nodes and links');
+
+    }
+
+    /**
+     * When any one of the inputs get changed by a containing component, this
+     * gets called automatically. In addition this is called manually by
+     * topology.service when a response is received from the WebSocket from the
+     * server
+     *
+     * The Devices, Hosts and SubRegions are all added to the Node list for the simulation
+     * The Links are added to the Link list of the simulation.
+     * Before they are added the Links are associated with Nodes based on their endPt
+     *
+     * @param changes - a list of changed @Input(s)
+     */
+    ngOnChanges(changes: SimpleChanges) {
+        if (changes['regionData']) {
+            const devices: Device[] =
+                changes['regionData'].currentValue.devices[this.visibleLayerIdx()];
+            const hosts: Host[] =
+                changes['regionData'].currentValue.hosts[this.visibleLayerIdx()];
+            const subRegions: SubRegion[] = changes['regionData'].currentValue.subRegion;
+            this.graph.nodes = [];
+            if (devices) {
+                this.graph.nodes = devices;
+            }
+            if (hosts) {
+                this.graph.nodes = this.graph.nodes.concat(hosts);
+            }
+            if (subRegions) {
+                this.graph.nodes = this.graph.nodes.concat(subRegions);
+            }
+
+            // If a node has a fixed location then assign it to fx and fy so
+            // that it doesn't get affected by forces
+            this.graph.nodes
+            .forEach((n) => {
+                const loc: Location = <Location>n['location'];
+                if (loc && loc.locType === LocationType.GEO) {
+                    const position: MetaUi =
+                        ZoomUtils.convertGeoToCanvas(
+                            <LocMeta>{lng: loc.longOrX, lat: loc.latOrY});
+                    n.fx = position.x;
+                    n.fy = position.y;
+                    this.log.debug('Found node', n.id, 'with', loc.locType);
+                }
+            });
+
+            // Associate the endpoints of each link with a real node
+            this.graph.links = [];
+            for (const linkIdx of Object.keys(this.regionData.links)) {
+                const epA = ForceSvgComponent.extractNodeName(
+                                        this.regionData.links[linkIdx].epA);
+                this.regionData.links[linkIdx].source =
+                    this.graph.nodes.find((node) =>
+                        node.id === epA);
+                const epB = ForceSvgComponent.extractNodeName(
+                    this.regionData.links[linkIdx].epB);
+                this.regionData.links[linkIdx].target =
+                    this.graph.nodes.find((node) =>
+                        node.id === epB);
+                this.regionData.links[linkIdx].index = Number(linkIdx);
+            }
+
+            this.graph.links = this.regionData.links;
+
+            this.graph.initSimulation(this.options);
+            this.graph.initNodes();
+            this.graph.initLinks();
+            this.log.debug('ForceSvgComponent input changed',
+                this.graph.nodes.length, 'nodes,', this.graph.links.length, 'links');
+        }
+
+        this.ref.markForCheck();
+    }
+
+    /**
+     * Get the index of LayerType so it can drive the visibility of nodes and
+     * hosts on layers
+     */
+    visibleLayerIdx(): number {
+        const layerKeys: string[] = Object.keys(LayerType);
+        for (const idx in layerKeys) {
+            if (LayerType[layerKeys[idx]] === this.visibleLayer) {
+                return Number(idx);
+            }
+        }
+        return -1;
+    }
+
+    selectLink(link: RegionLink): void {
+        this.selectedLink = link;
+        this.linkSelected.emit(link);
+    }
+
+    get options() {
+        return this._options = {
+            width: window.innerWidth,
+            height: window.innerHeight
+        };
+    }
+
+    /**
+     * Iterate through all hosts and devices to deselect the previously selected
+     * node. The emit an event to the parent that lets it know the selection has
+     * changed.
+     * @param selectedNode the newly selected node
+     */
+    updateSelected(selectedNode: UiElement): void {
+        this.log.debug('Node or link selected', selectedNode ? selectedNode.id : 'none');
+        this.devices
+            .filter((d) =>
+                selectedNode === undefined || d.device.id !== selectedNode.id)
+            .forEach((d) => d.deselect());
+        this.hosts
+            .filter((h) =>
+                selectedNode === undefined || h.host.id !== selectedNode.id)
+            .forEach((h) => h.deselect());
+
+        this.links
+            .filter((l) =>
+                selectedNode === undefined || l.link.id !== selectedNode.id)
+            .forEach((l) => l.deselect());
+        // Push the changes back up to parent (Topology Component)
+        this.selectedNodeEvent.emit(selectedNode);
+    }
+
+    /**
+     * We want to filter links to show only those not related to hosts if the
+     * 'showHosts' flag has been switched off. If 'showHosts' is true, then
+     * display all links.
+     */
+    filteredLinks(): Link[] {
+        return this.regionData.links.filter((h) =>
+            this.showHosts ||
+            ((<Host>h.source).nodeType !== 'host' &&
+            (<Host>h.target).nodeType !== 'host'));
+    }
+
+    /**
+     * When changes happen in the model, then model events are sent up through the
+     * Web Socket
+     * @param type - the type of the change
+     * @param memo - a qualifier on the type
+     * @param subject - the item that the update is for
+     * @param data - the new definition of the item
+     */
+    handleModelEvent(type: ModelEventType, memo: ModelEventMemo, subject: string, data: UiElement): void {
+        switch (type) {
+            case ModelEventType.DEVICE_ADDED_OR_UPDATED:
+                if (memo === ModelEventMemo.ADDED) {
+                    const loc = (<Device>data).location;
+                    if (loc && loc.locType === LocationType.GEO) {
+                        const position =
+                            ZoomUtils.convertGeoToCanvas(<LocMeta>{ lng: loc.longOrX, lat: loc.latOrY});
+                        (<Device>data).fx = position.x;
+                        (<Device>data).fy = position.y;
+                        this.log.debug('Using long', loc.longOrX, 'lat', loc.latOrY, '(', position.x, position.y, ')');
+                    } else if (loc && loc.locType === LocationType.GRID) {
+                        (<Device>data).fx = loc.longOrX;
+                        (<Device>data).fy = loc.latOrY;
+                        this.log.debug('Using grid', loc.longOrX, loc.latOrY);
+                    } else {
+                        (<Device>data).fx = null;
+                        (<Device>data).fy = null;
+                        // (<Device>data).x = 500;
+                        // (<Device>data).y = 500;
+                    }
+                    this.graph.nodes.push(<Device>data);
+                    this.regionData.devices[this.visibleLayerIdx()].push(<Device>data);
+                    this.log.debug('Device added', (<Device>data).id);
+                } else if (memo === ModelEventMemo.UPDATED) {
+                    const oldDevice: Device =
+                        this.regionData.devices[this.visibleLayerIdx()]
+                            .find((d) => d.id === subject);
+                    const changes = ForceSvgComponent.updateObject(oldDevice, <Device>data);
+                    if (changes > 0) {
+                        this.log.debug('Device ', oldDevice.id, memo, ' - ', changes, 'changes');
+                    }
+                } else {
+                    this.log.warn('Device ', memo, ' - not yet implemented', data);
+                }
+                break;
+            case ModelEventType.HOST_ADDED_OR_UPDATED:
+                if (memo === ModelEventMemo.ADDED) {
+                    this.regionData.hosts[this.visibleLayerIdx()].push(<Host>data);
+                    this.graph.nodes.push(<Host>data);
+                    this.log.debug('Host added', (<Host>data).id);
+                } else if (memo === ModelEventMemo.UPDATED) {
+                    const oldHost: Host = this.regionData.hosts[this.visibleLayerIdx()]
+                        .find((h) => h.id === subject);
+                    const changes = ForceSvgComponent.updateObject(oldHost, <Host>data);
+                    if (changes > 0) {
+                        this.log.debug('Host ', oldHost.id, memo, ' - ', changes, 'changes');
+                    }
+                } else {
+                    this.log.warn('Host change', memo, ' - unexpected');
+                }
+                break;
+            case ModelEventType.DEVICE_REMOVED:
+                if (memo === ModelEventMemo.REMOVED || memo === undefined) {
+                    const removeIdx: number =
+                        this.regionData.devices[this.visibleLayerIdx()]
+                            .findIndex((d) => d.id === subject);
+                    this.regionData.devices[this.visibleLayerIdx()].splice(removeIdx, 1);
+                    this.removeRelatedLinks(subject);
+                    this.log.debug('Device ', subject, 'removed. Links', this.regionData.links);
+                } else {
+                    this.log.warn('Device removed - unexpected memo', memo);
+                }
+                break;
+            case ModelEventType.HOST_REMOVED:
+                if (memo === ModelEventMemo.REMOVED || memo === undefined) {
+                    const removeIdx: number =
+                        this.regionData.hosts[this.visibleLayerIdx()]
+                            .findIndex((h) => h.id === subject);
+                    this.regionData.hosts[this.visibleLayerIdx()].splice(removeIdx, 1);
+                    this.removeRelatedLinks(subject);
+                    this.log.warn('Host ', subject, 'removed');
+                } else {
+                    this.log.warn('Host removed - unexpected memo', memo);
+                }
+                break;
+            case ModelEventType.LINK_ADDED_OR_UPDATED:
+                if (memo === ModelEventMemo.ADDED &&
+                    this.regionData.links.findIndex((l) => l.id === subject) === -1) {
+                    const listLen = this.regionData.links.push(<RegionLink>data);
+                    const epA = ForceSvgComponent.extractNodeName(
+                        this.regionData.links[listLen - 1].epA);
+                    this.regionData.links[listLen - 1].source =
+                        this.graph.nodes.find((node) =>
+                            node.id === epA);
+                    const epB = ForceSvgComponent.extractNodeName(
+                        this.regionData.links[listLen - 1].epB);
+                    this.regionData.links[listLen - 1].target =
+                        this.graph.nodes.find((node) =>
+                            node.id === epB);
+                    this.log.debug('Link added', subject);
+                } else if (memo === ModelEventMemo.UPDATED) {
+                    const oldLink = this.regionData.links.find((l) => l.id === subject);
+                    const changes = ForceSvgComponent.updateObject(oldLink, <RegionLink>data);
+                    this.log.debug('Link ', subject, '. Updated', changes, 'items');
+                } else {
+                    this.log.warn('Link added or updated - unexpected memo', memo);
+                }
+                break;
+            default:
+                this.log.error('Unexpected model event', type, 'for', subject);
+        }
+        this.ref.markForCheck();
+        this.graph.initSimulation(this.options);
+        this.graph.initNodes();
+        this.graph.initLinks();
+    }
+
+    private removeRelatedLinks(subject: string) {
+        const len = this.regionData.links.length;
+        for (let i = 0; i < len; i++) {
+            const linkIdx = this.regionData.links.findIndex((l) =>
+                (ForceSvgComponent.extractNodeName(l.epA) === subject ||
+                    ForceSvgComponent.extractNodeName(l.epB) === subject));
+            if (linkIdx >= 0) {
+                this.regionData.links.splice(linkIdx, 1);
+                this.log.debug('Link ', linkIdx, 'removed on attempt', i);
+            }
+        }
+    }
+
+    /**
+     * When traffic monitoring is turned on (A key) highlights will be sent back
+     * from the WebSocket through the Traffic Service
+     * @param devices - an array of device highlights
+     * @param hosts - an array of host highlights
+     * @param links - an array of link highlights
+     */
+    handleHighlights(devices: Device[], hosts: Host[], links: LinkHighlight[]): void {
+
+        if (devices.length > 0) {
+            this.log.debug(devices.length, 'Devices highlighted');
+            devices.forEach((dh) => {
+                const deviceComponent: DeviceNodeSvgComponent = this.devices.find((d) => d.device.id === dh.id );
+                if (deviceComponent) {
+                    deviceComponent.ngOnChanges(
+                        {'deviceHighlight': new SimpleChange(<Device>{}, dh, true)}
+                    );
+                    this.log.debug('Highlighting device', deviceComponent.device.id);
+                } else {
+                    this.log.warn('Device component not found', dh.id);
+                }
+            });
+        }
+        if (hosts.length > 0) {
+            this.log.debug(hosts.length, 'Hosts highlighted');
+            hosts.forEach((hh) => {
+                const hostComponent: HostNodeSvgComponent = this.hosts.find((h) => h.host.id === hh.id );
+                if (hostComponent) {
+                    hostComponent.ngOnChanges(
+                        {'hostHighlight': new SimpleChange(<Host>{}, hh, true)}
+                    );
+                    this.log.debug('Highlighting host', hostComponent.host.id);
+                }
+            });
+        }
+        if (links.length > 0) {
+            this.log.debug(links.length, 'Links highlighted');
+            links.forEach((lh) => {
+                const linkComponent: LinkSvgComponent = this.links.find((l) => l.link.id === lh.id );
+                if (linkComponent) { // A link might not be present is hosts viewing is switched off
+                    linkComponent.ngOnChanges(
+                        {'linkHighlight': new SimpleChange(<LinkHighlight>{}, lh, true)}
+                    );
+                    // this.log.debug('Highlighting link', linkComponent.link.id, lh.css, lh.label);
+                }
+            });
+        }
+    }
+
+    /**
+     * As nodes are dragged around the graph, their new location should be sent
+     * back to server
+     * @param klass The class of node e.g. 'host' or 'device'
+     * @param id - the ID of the node
+     * @param newLocation - the new Location of the node
+     */
+    nodeMoved(klass: string, id: string, newLocation: MetaUi) {
+        this.wss.sendEvent('updateMeta', <UpdateMeta>{
+            id: id,
+            class: klass,
+            memento: newLocation
+        });
+        this.log.debug(klass, id, 'has been moved to', newLocation);
+    }
+
+    resetNodeLocations() {
+        this.devices.forEach((d) => {
+            d.resetNodeLocation();
+        });
+    }
+}
+