Repository: qpid-dispatch Updated Branches: refs/heads/master a6a4445cd -> 55e08784d
DISPATCH-970 Added Message flow page to console Project: http://git-wip-us.apache.org/repos/asf/qpid-dispatch/repo Commit: http://git-wip-us.apache.org/repos/asf/qpid-dispatch/commit/55e08784 Tree: http://git-wip-us.apache.org/repos/asf/qpid-dispatch/tree/55e08784 Diff: http://git-wip-us.apache.org/repos/asf/qpid-dispatch/diff/55e08784 Branch: refs/heads/master Commit: 55e08784d489d6302b8e4242f22213046c469f80 Parents: a6a4445 Author: Ernest Allen <eal...@redhat.com> Authored: Wed Apr 18 15:32:59 2018 -0400 Committer: Ernest Allen <eal...@redhat.com> Committed: Wed Apr 18 15:32:59 2018 -0400 ---------------------------------------------------------------------- console/stand-alone/index.html | 12 +- console/stand-alone/package.json | 2 + console/stand-alone/plugin/html/qdrChord.html | 218 +++++ console/stand-alone/plugin/js/chord/data.js | 228 +++++ console/stand-alone/plugin/js/chord/filters.js | 107 +++ .../plugin/js/chord/layout/README.md | 66 ++ .../plugin/js/chord/layout/layout.js | 147 ++++ console/stand-alone/plugin/js/chord/matrix.js | 199 +++++ console/stand-alone/plugin/js/chord/qdrChord.js | 823 +++++++++++++++++++ .../plugin/js/chord/ribbon/README.md | 22 + .../plugin/js/chord/ribbon/ribbon.js | 165 ++++ console/stand-alone/plugin/js/dispatchPlugin.js | 5 +- console/stand-alone/plugin/js/navbar.js | 7 + 13 files changed, 1996 insertions(+), 5 deletions(-) ---------------------------------------------------------------------- http://git-wip-us.apache.org/repos/asf/qpid-dispatch/blob/55e08784/console/stand-alone/index.html ---------------------------------------------------------------------- diff --git a/console/stand-alone/index.html b/console/stand-alone/index.html index 17768cb..d3deb00 100644 --- a/console/stand-alone/index.html +++ b/console/stand-alone/index.html @@ -119,12 +119,14 @@ under the License. <script src='node_modules/d3-queue/build/d3-queue.min.js'></script> <script src='node_modules/d3-time/build/d3-time.min.js'></script> <script src='node_modules/d3-time-format/build/d3-time-format.min.js'></script> +<script src='node_modules/d3-path/build/d3-path.min.js'></script> <!-- c3 for charts --> <script src="node_modules/c3/c3.js"></script> <script src="node_modules/angular-ui-slider/src/slider.js"></script> <script src="node_modules/angular-ui-grid/ui-grid.js"></script> +<script src="node_modules/angular-bootstrap-checkbox/angular-bootstrap-checkbox.js"></script> <script src="node_modules/notifyjs-browser/dist/notify.js"></script> <script src="node_modules/patternfly/dist/js/patternfly.min.js"></script> @@ -145,10 +147,12 @@ under the License. <script type="text/javascript" src="node_modules/dispatch-console-pages/dist/js/qdrChartService.js"></script> <script type="text/javascript" src="node_modules/dispatch-console-pages/dist/js/qdrTopology.js"></script> <script type="text/javascript" src="node_modules/dispatch-console-pages/dist/js/qdrSettings.js"></script> - -<script type="text/javascript"> - //angular.element(document.getElementsByTagName('head')).append(angular.element('<base href="' + window.location.pathname + '" />')); - </script> +<script type="text/javascript" src="plugin/js/chord/ribbon/ribbon.js"></script> +<script type="text/javascript" src="plugin/js/chord/matrix.js"></script> +<script type="text/javascript" src="plugin/js/chord/filters.js"></script> +<script type="text/javascript" src="plugin/js/chord/data.js"></script> +<script type="text/javascript" src="plugin/js/chord/layout/layout.js"></script> +<script type="text/javascript" src="plugin/js/chord/qdrChord.js"></script> </body> </html> http://git-wip-us.apache.org/repos/asf/qpid-dispatch/blob/55e08784/console/stand-alone/package.json ---------------------------------------------------------------------- diff --git a/console/stand-alone/package.json b/console/stand-alone/package.json index aedaaf6..ac32845 100644 --- a/console/stand-alone/package.json +++ b/console/stand-alone/package.json @@ -24,6 +24,7 @@ "dependencies": { "angular": "1.5.11", "angular-animate": "1.5.11", + "angular-bootstrap-checkbox": "^0.5.0", "angular-resource": "1.5.11", "angular-route": "1.5.11", "angular-sanitize": "1.5.11", @@ -34,6 +35,7 @@ "bootstrap": "^3.3.7", "c3": "^0.4.18", "d3": "^3.5.14", + "d3-path": "^1.0.5", "d3-queue": "^3.0.7", "d3-time-format": "^2.1.1", "dispatch-console-pages": "~0.1.6", http://git-wip-us.apache.org/repos/asf/qpid-dispatch/blob/55e08784/console/stand-alone/plugin/html/qdrChord.html ---------------------------------------------------------------------- diff --git a/console/stand-alone/plugin/html/qdrChord.html b/console/stand-alone/plugin/html/qdrChord.html new file mode 100644 index 0000000..a1b055a --- /dev/null +++ b/console/stand-alone/plugin/html/qdrChord.html @@ -0,0 +1,218 @@ +<style> + path.chord { + fill-opacity: .67; + } + #circle circle { + fill: none; + pointer-events: all; + } + path.fade { + opacity: 0.1; + } + + .routers rect { + fill: white; + } + + g.arc text { + fill: black; + stroke-width: 0; + } + #chord { + position: absolute; + } + + #switches { + position: absolute; + top: 1em; + margin-right: 1em; + padding-right: 1em; + opacity: 0; + } + #switches ul { + list-style: none; + } + + #switches li { + margin-bottom: 1em; + } + #legend { + position: absolute; + right: 1em; + top: 1em; + padding: 0 1em; + border: 1px solid black; + border-radius: 4px; + min-width: 10em; + background-color: white; + } + #legend .legend-color { + width: 19.6667px; + height: 18.1667px; + display: inline-block; + } + #legend .legend-address { + white-space: nowrap; + padding: 0.1em 0.5em 0.2em; + position: absolute; + left: 10px; + top: -5px; + } + + #legend .legend-router { + white-space: nowrap; + position: relative; + top: -5px; + } + + #legend .legend-line { + margin: 0.5em 1em; + border: 1px solid white; + } + #legend .legend-line:hover { + border: 1px dashed lightslategray; + } + #legend ul { + list-style: none; + padding-left: 0; + margin-bottom: 0.5em; + } + #legend ul li label input[type='checkbox'] { + position: relative; + top: 2px; + padding-right: 1em; + } + #legend ul li label { + margin-bottom: 0; + font-weight: normal; + } + /* the checkboxes for the addresses */ + #legend ul li input[type=checkbox]:checked + label::before { + content:'\2713'; + font-weight: bold; + font-size: 16px; + display:inline-block; + /* padding:0 6px 0 0; */ + color: black; + position: absolute; + top: -8px; + left: -1px; + } + /* The aggregate addresses need a black checkbox on the white background */ + #legend ul li input[type=checkbox]:checked + label.aggregate::before { + color: black; + /* left: 1px; */ + } + .bootstrap-switch { + border: 0px; + } + .bootstrap-switch.bootstrap-switch-small { + min-width: 72px; + } + .legend-address { + color: black; + } + + .arrows path { + stroke: black; + opacity: 0.25; + stroke-width: 3; + } + + path.empty { + fill: rgb(31, 119, 180); + fill-opacity: .67; + } + +#legend ul.addresses li, #legend ul.routers li { + margin-top: 0.5em; +} + +#legend ul.routers li { + margin-bottom: 0.5em; +} +#legend ul.addresses { + margin-bottom: 1.5em; +} + +.code-branch:before { + font-style: normal; + font-family: FontAwesome; + content: "\f126"; +} + + .legend-min-address { + padding-left: 15px; + opacity: 0; + z-index: 0; + } + + div#debugging { + width: 20em; + } + + .addresses .btn, #switches .btn { + padding: 0px 4px !important; + background-image: none; + box-shadow: 0 0 0; + border-color: black; + } + + #popover-div { + position: absolute; + z-index: 200; + border-radius: 4px; + background-color: black; + color: white; + opacity: .95; + padding: 12px; + font-size: 14px; + display: none; + } + + #chord text { + pointer-events: all; + } + + #noTraffic { + position:absolute; + } + </style> + + <div ng-controller="QDR.ChordController"> + <div id="popover-div" ng-bind-html="trustedpopoverContent"></div> + <div id="noTraffic"></div> + <div id="chord"></div> + <div id="legend"> + <div ng-hide="arcColorsEmpty()"> + <h4>Routers</h4> + <ul class="routers"> + <li ng-repeat="(router, color) in arcColors" class="legend-line" ng-mouseover="enterRouter(router)" ng-mouseleave="leaveLegend()"> + <span class='legend-color' ng-style="{'background-color': color}"></span> + <span class='legend-router' title="{{router}}">{{router | limitTo:15}}{{router.length>15 ? 'â¦' : ''}}</span> + </li> + </ul> + </div> + <h4>Addresses</h4> + <ul class="addresses"> + <li ng-repeat="(address, color) in addresses" class="legend-line"> + <checkbox style="background-color: {{chordColors[address]}};" + title="{{address}}" ng-change="addressFilterChanged()" + ng-model="addresses[address]"></checkbox> + <span ng-mouseenter="enterLegend(address)" ng-mouseleave="leaveLegend()" ng-click="addressClick(address)" title="{{address}}">{{address | limitTo : 15}}{{address.length>15 ? 'â¦' : ''}}</span> + </li> + </ul> + </div> + <div id="switches"> + <ul> + <li> + <checkbox title="Select to show message rates" ng-model="legendOptions.isRate"></checkbox> + <span title="Select to show message rates">Show rates</span> + </li> + <li> + <checkbox title="Select to break out traffic by address" ng-model="legendOptions.byAddress"></checkbox> + <span title="Select to break out traffic by address">Show by address</span> + </li> + </ul> + </div> + </div> http://git-wip-us.apache.org/repos/asf/qpid-dispatch/blob/55e08784/console/stand-alone/plugin/js/chord/data.js ---------------------------------------------------------------------- diff --git a/console/stand-alone/plugin/js/chord/data.js b/console/stand-alone/plugin/js/chord/data.js new file mode 100644 index 0000000..7f28a9c --- /dev/null +++ b/console/stand-alone/plugin/js/chord/data.js @@ -0,0 +1,228 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one +or more contributor license agreements. See the NOTICE file +distributed with this work for additional information +regarding copyright ownership. The ASF licenses this file +to you 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. +*/ + +'use strict'; +/* global angular Promise MIN_CHORD_THRESHOLD */ + +const SAMPLES = 3; // number of snapshots to use for rate calculations + +function ChordData (QDRService, isRate, converter) { + this.QDRService = QDRService; + this.last_matrix = undefined; + this.last_values = {values: undefined, timestamp: undefined}; + this.rateValues = undefined; + this.snapshots = []; // last N values used for calculating rate + this.isRate = isRate; + // fn to convert raw data to matrix + this.converter = converter; + // object that determines which addresses are excluded + this.filter = []; +} +ChordData.prototype.setRate = function (isRate) { + this.isRate = isRate; +}; +ChordData.prototype.setConverter = function (converter) { + this.converter = converter; +}; +ChordData.prototype.setFilter = function (filter) { + this.filter = filter; +}; +ChordData.prototype.getAddresses = function () { + let addresses = {}; + let outer = this.snapshots; + if (outer.length === 0) + outer = outer = [this.last_values]; + outer.forEach( function (snap) { + snap.values.forEach( function (lv) { + if (!(lv.address in addresses)) { + addresses[lv.address] = this.filter.indexOf(lv.address) < 0; + } + }, this); + }, this); + return addresses; +}; +ChordData.prototype.getRouters = function () { + let routers = {}; + let outer = this.snapshots; + if (outer.length === 0) + outer = [this.last_values]; + outer.forEach( function (snap) { + snap.values.forEach( function (lv) { + routers[lv.egress] = true; + routers[lv.ingress] = true; + }); + }); + return Object.keys(routers).sort(); +}; + +ChordData.prototype.applyFilter = function (filter) { + if (filter) + this.setFilter(filter); + + return new Promise( (function (resolve) { + resolve(convert(this, this.last_values)); + })); +}; + +// construct a square matrix of the number of messages each router has egressed from each router +ChordData.prototype.getMatrix = function () { + let self = this; + return new Promise( (function (resolve, reject) { + // get the router.node and router.link info + self.QDRService.management.topology.fetchAllEntities([ + {entity: 'router.node', attrs: ['id', 'index']}, + {entity: 'router.link', attrs: ['linkType', 'linkDir', 'owningAddr', 'ingressHistogram']}], + function(results) { + if (!results) { + reject(Error('unable to fetch entities')); + return; + } + // the raw data received from the rouers + let values = []; + + // for each router in the network + for (let nodeId in results) { + // get a map of router ids to index into ingressHistogram for the links for this router. + // each routers has a different order for the routers + let ingressRouters = []; + let routerNode = results[nodeId]['router.node']; + let idIndex = routerNode.attributeNames.indexOf('id'); + + // ingressRouters is an array of router names in the same order that the ingressHistogram values will be in + for (let i=0; i<routerNode.results.length; i++) { + ingressRouters.push(routerNode.results[i][idIndex]); + } + + // the name of the router we are working on + let egressRouter = self.QDRService.management.topology.nameFromId(nodeId); + + // loop through the router links for this router looking for out/endpoint/non-console links + let routerLinks = results[nodeId]['router.link']; + for (let i=0; i<routerLinks.results.length; i++) { + let link = self.QDRService.utilities.flatten(routerLinks.attributeNames, routerLinks.results[i]); + // if the link is an outbound/enpoint/non console + if (link.linkType === 'endpoint' && link.linkDir === 'out' && !link.owningAddr.startsWith('Ltemp.')) { + // keep track of the raw egress values as well as their ingress and egress routers and the address + for (let j=0; j<ingressRouters.length; j++) { + let messages = link.ingressHistogram[j]; + if (messages) { + values.push({ingress: ingressRouters[j], + egress: egressRouter, + address: self.QDRService.utilities.addr_text(link.owningAddr), + messages: messages}); + } + } + } + } + } + // values is an array of objects like [{ingress: 'xxx', egress: 'xxx', address: 'xxx', messages: ###}, ....] + + // convert the raw values array into a matrix object + let matrix = convert(self, values); + + // resolve the promise + resolve(matrix); + }); + })); +}; +ChordData.prototype.convertUsing = function (converter) { + let values = this.isRate ? this.rateValues : this.last_values.values; + + // convert the values to a matrix using the requested converter and the current filter + return converter(values, this.filter); +}; + +// Private functions + +// compare the current values to the last_values and return the rate/second +let calcRate = function (values, last_values, snapshots) { + let now = Date.now(); + if (!last_values.values) { + last_values.values = values; + last_values.timestamp = now - 1000; + } + + // ensure the snapshots are initialized + if (snapshots.length < SAMPLES) { + for (let i=0; i<SAMPLES; i++) { + if (snapshots.length < i+1) { + snapshots[i] = angular.copy(last_values); + snapshots[i].timestamp = now - (1000 * (SAMPLES-i)); + } + } + } + // remove oldest sample + snapshots.shift(); + // add the new values to the end. + snapshots.push(angular.copy(last_values)); + + let oldest = snapshots[0]; + let rateValues = []; + let elapsed = (now - oldest.timestamp) / 1000; + values.forEach( function (value) { + + let rate = 0; + let total = 0; + snapshots.forEach ( function (snap) { + let last_index = snap.values.findIndex( function (lv) { + return lv.ingress === value.ingress && + lv.egress === value.egress && + lv.address === value.address; + }); + if (last_index >= 0) { + total += snap.values[last_index].messages; + } + }); + rate = (value.messages - (total / snapshots.length)) / elapsed; + + rateValues.push({ingress: value.ingress, + egress: value.egress, + address: value.address, + messages: Math.max(rate, MIN_CHORD_THRESHOLD) + }); + }); + return rateValues; +}; + +let genKeys = function (values) { + values.forEach( function (value) { + value.key = value.egress + value.ingress + value.address; + }); +}; +let sortByKeys = function (values) { + return values.sort( function (a, b) { + return a.key > b.key ? 1 : a.key < b.key ? -1 : 0; + }); +}; +let convert = function (self, values) { + // sort the raw data by egress router name + genKeys(values); + sortByKeys(values); + + self.last_values.values = angular.copy(values); + self.last_values.timestamp = Date.now(); + if (self.isRate) { + self.rateValues = values = calcRate(values, self.last_values, self.snapshots); + } + // convert the raw data to a matrix + let matrix = self.converter(values, self.filter); + self.last_matrix = matrix; + + return matrix; +}; http://git-wip-us.apache.org/repos/asf/qpid-dispatch/blob/55e08784/console/stand-alone/plugin/js/chord/filters.js ---------------------------------------------------------------------- diff --git a/console/stand-alone/plugin/js/chord/filters.js b/console/stand-alone/plugin/js/chord/filters.js new file mode 100644 index 0000000..7bb68cc --- /dev/null +++ b/console/stand-alone/plugin/js/chord/filters.js @@ -0,0 +1,107 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one +or more contributor license agreements. See the NOTICE file +distributed with this work for additional information +regarding copyright ownership. The ASF licenses this file +to you 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. +*/ +'use strict'; +/* global valuesMatrix */ + +// this filter will show an arc per router with the addresses aggregated +var aggregateAddresses = function (values, filter) { // eslint-disable-line no-unused-vars + let m = new valuesMatrix(true); + values.forEach (function (value) { + if (filter.indexOf(value.address) < 0) { + let chordName = value.egress; + let egress = value.ingress; + let row = m.indexOf(chordName); + if (row < 0) { + row = m.addRow(chordName, value.ingress, value.egress, value.address); + } + let col = m.indexOf(egress); + if (col < 0) { + col = m.addRow(egress, value.ingress, value.egress, value.address); + } + m.addValue(row, col, value); + } + }); + return m.sorted(); +}; + +// this filter will show an arc per router-address +var _separateAddresses = function (values, filter) { // eslint-disable-line no-unused-vars + let m = new valuesMatrix(false); + values = values.filter( function (v) { return filter.indexOf(v.address) < 0;}); + if (values.length === 0) + return m; + + let addresses = {}, routers = {}; + // get the list of routers and addresses in the data + values.forEach( function (value) { + addresses[value.address] = true; + routers[value.ingress] = true; + routers[value.egress] = true; + }); + let saddresses = Object.keys(addresses).sort(); + let srouters = Object.keys(routers).sort(); + let alen = saddresses.length; + // sanity check + if (alen === 0) + return m; + + /* Convert the data to a matrix */ + + // initialize the matrix to have the correct ingress, egress, and address in each row and col + m.zeroInit(saddresses.length * srouters.length); + m.rows.forEach( function (row, r) { + let egress = srouters[Math.floor(r/alen)]; + row.cols.forEach( function (col, c) { + let ingress = srouters[Math.floor(c/alen)]; + let address = saddresses[c % alen]; + m.setRowCol(r, c, ingress, egress, address, 0); + }); + }); + // set the values at each cell in the matrix + for (let i=0, alen=saddresses.length, vlen=values.length; i<vlen; i++) { + let value = values[i]; + let egressIndex = srouters.indexOf(value.egress); + let ingressIndex = srouters.indexOf(value.ingress); + let addressIndex = saddresses.indexOf(value.address); + let row = egressIndex * alen + addressIndex; + let col = ingressIndex * alen + addressIndex; + m.setColMessages(row, col, value.messages); + } + return m; +}; + +let separateAddresses = function (values, filter) { // eslint-disable-line no-unused-vars + let m = new valuesMatrix(false); + values.forEach( function (value) { + if (filter.indexOf(value.address) < 0) { + let egressChordName = value.egress + value.ingress + value.address; + let r = m.indexOf(egressChordName); + if (r < 0) { + r = m.addRow(egressChordName, value.ingress, value.egress, value.address); + } + let ingressChordName = value.ingress + value.egress + value.address; + let c = m.indexOf(ingressChordName); + if (c < 0) { + c = m.addRow(ingressChordName, value.egress, value.ingress, value.address); + } + m.addValue(r, c, value); + } + }); + return m.sorted(); +}; http://git-wip-us.apache.org/repos/asf/qpid-dispatch/blob/55e08784/console/stand-alone/plugin/js/chord/layout/README.md ---------------------------------------------------------------------- diff --git a/console/stand-alone/plugin/js/chord/layout/README.md b/console/stand-alone/plugin/js/chord/layout/README.md new file mode 100644 index 0000000..2a17d29 --- /dev/null +++ b/console/stand-alone/plugin/js/chord/layout/README.md @@ -0,0 +1,66 @@ + This is a replacement for d3.layout.chord(). + It implements a groubBy feature that allows arcs to be grouped. + It does not implement the sortBy features of d3.layout.chord. + + API: + qrdlayoutChord().padding(ARCPADDING).groupBy(groupByIndexes).matrix(matrix); + where groupByIndexes is an array of integers. + + When grouping arcs together, you are taking multiple source arcs and combining them into a single target arc. + With grouping you can end up with multiple chords that start and stop on the same arc[s]. + + Each element in the groupByIndexes array corresponds to a row in the matrix, therefore the array + should be matrix.length long. The position in the groupByIndexes array specifies the + source arc. The value at that position determines the target arc. If the groupByIndexes array has 2 unique + values (0 and 1) then there will be 2 groups returned. + + For example: With a matrix of + [[1,2], + [3,4]] + that represents the trips between 2 neighbourhoods: A and B. + d3 would normally generate 2 arcs and 3 chords. + The 1st arc corresponds to A with data of [1.2], and the 2nd to B with data of [3,4]. + Chord 1 would be from A to A with a value of 1. + Chord 2 would be between A and B with B having a value of 3 and A having a value of 2 + Chord 3 would be from B to B with a value of 4. + + If you had data that splits those same trips into by bike and on foot, + you could generate a more detailed matrix like: + [[0,0,0,1], + [0,1,1,0], + [0,2,3,0], + [1,0,0,1] + ] + + This would generate 4 arcs and 5 chords. + The chords would be: + A foot - A foot value 1 + A bike - B bike values 1 and 1 + B foot - A foot values 1 and 2 + B bike - B bike value 3 + B foot - B foot value 1 + + But you don't want 4 arcs: A bike, A foot, B bike, and B foot. You want 2 arcs A and B with chords + between them that represent the bike and foot trips. + Even though you could color the A bike and A foot arcs the same, there would still be a gap between them + and if you switched between the detailed matrix and the aggregate matrix, the arcs would move. + Also, with 4 arcs, the arc labels could get unruly. + + One possible kludge would be to generate the detailed diagram with 0 padding between arcs and + insert dummy rows and columns between the groups. + The values for the dummy entries would need to be calculated so that their arc size exactly corresponded + to the normal padding. + The arcs and chords for the dummy data would have opacity 0 and not respond to mouse events. You'd also have to + create and position the labels separatly from the arcs. + + Or... you could use groupBy. + The detail matrix would stay the same. The output chords would be the same. The only change would be + that the arcs A bike and A foot would be combined, and the arcs B bike and B foot would be combined. + In the above example you set the groupBy array to [0,0,1,1]. + This says the 1st two arcs get grouped together into a new arc 0, and the 2nd two arcs get grouped into + a new arc 1. + + Since there can be chords that have the same source.index and source.subindex and the same target.index and + target.subindex, two additional data values are returned in the chord's source and target data structures: + orgindex and orgsubindex. This will let you determine whether the chord is for + bike trips or foot trips. http://git-wip-us.apache.org/repos/asf/qpid-dispatch/blob/55e08784/console/stand-alone/plugin/js/chord/layout/layout.js ---------------------------------------------------------------------- diff --git a/console/stand-alone/plugin/js/chord/layout/layout.js b/console/stand-alone/plugin/js/chord/layout/layout.js new file mode 100644 index 0000000..e3d223f --- /dev/null +++ b/console/stand-alone/plugin/js/chord/layout/layout.js @@ -0,0 +1,147 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one +or more contributor license agreements. See the NOTICE file +distributed with this work for additional information +regarding copyright ownership. The ASF licenses this file +to you 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. +*/ +'use strict'; +/* global d3 */ + + +var qrdlayoutChord = function() { // eslint-disable-line no-unused-vars + var chord = {}, chords, groups, matrix, n, padding = 0, Ï = Math.PI*2, groupBy; + function relayout() { + groupBy = groupBy || d3.range(n); + // number of unique values in the groupBy array. This will be the number + // of groups generated. + var groupLen = unique(groupBy); + var subgroups = {}, groupSums = fill(0, groupLen), k, x, x0, i, j, di, ldi; + + chords = []; + groups = []; + + // calculate the sum of the values for each group + k = 0, i = -1; + while (++i < n) { + x = 0, j = -1; + while (++j < n) { + x += matrix[i][j]; + } + groupSums[groupBy[i]] += x; + k += x; + } + // the fraction of the circle for each incremental value + k = (Ï - padding * groupLen) / k; + // for each row + x = 0, i = -1, ldi = groupBy[0]; + while (++i < n) { + di = groupBy[i]; + // insert padding after each group + if (di !== ldi) { + x += padding; + ldi = di; + } + // for each column + x0 = x, j = -1; + while (++j < n) { + var dj = groupBy[j], v = matrix[i][j], a0 = x, a1 = x += v * k; + // create a structure for each cell in the matrix. these are the potential chord ends + subgroups[i + '-' + j] = { + index: di, + subindex: dj, + orgindex: i, + orgsubindex: j, + startAngle: a0, + endAngle: a1, + value: v + }; + } + if (!groups[di]) { + // create a new group (arc) + groups[di] = { + index: di, + startAngle: x0, + endAngle: x, + value: groupSums[di] + }; + } else { + // bump up the ending angle of the combined arc + groups[di].endAngle = x; + } + } + + // put the chord ends together into a chords. + i = -1; + while (++i < n) { + j = i - 1; + while (++j < n) { + var source = subgroups[i + '-' + j], target = subgroups[j + '-' + i]; + // Only make a chord if there is a value at one of the two ends + if (source.value || target.value) { + chords.push(source.value < target.value ? { + source: target, + target: source + } : { + source: source, + target: target + }); + } + } + } + } + chord.matrix = function(x) { + if (!arguments.length) return matrix; + n = (matrix = x) && matrix.length; + chords = groups = null; + return chord; + }; + chord.padding = function(x) { + if (!arguments.length) return padding; + padding = x; + chords = groups = null; + return chord; + }; + chord.groupBy = function (x) { + if (!arguments.length) return groupBy; + groupBy = x; + chords = groups = null; + return chord; + }; + chord.chords = function() { + if (!chords) relayout(); + return chords; + }; + chord.groups = function() { + if (!groups) relayout(); + return groups; + }; + return chord; +}; + +let fill = function (value, length) { + var i=0, array = []; + array.length = length; + while(i < length) + array[i++] = value; + return array; +}; + +let unique = function (arr) { + var counts = {}; + for (var i = 0; i < arr.length; i++) { + counts[arr[i]] = 1 + (counts[arr[i]] || 0); + } + return Object.keys(counts).length; +}; http://git-wip-us.apache.org/repos/asf/qpid-dispatch/blob/55e08784/console/stand-alone/plugin/js/chord/matrix.js ---------------------------------------------------------------------- diff --git a/console/stand-alone/plugin/js/chord/matrix.js b/console/stand-alone/plugin/js/chord/matrix.js new file mode 100644 index 0000000..d8b9456 --- /dev/null +++ b/console/stand-alone/plugin/js/chord/matrix.js @@ -0,0 +1,199 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one +or more contributor license agreements. See the NOTICE file +distributed with this work for additional information +regarding copyright ownership. The ASF licenses this file +to you 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. +*/ +'use strict'; +/* global d3 */ + +const MIN_CHORD_THRESHOLD = 0.01; + +// public Matrix object +function valuesMatrix(aggregate) { + this.rows = []; + this.aggregate = aggregate; +} +// a matrix row +let valuesMatrixRow = function(r, chordName, ingress, egress) { + this.chordName = chordName || ''; + this.ingress = ingress || ''; + this.egress = egress || ''; + this.index = r; + this.cols = []; + for (let c=0; c<r; c++) { + this.addCol(0); + } +}; +// a matrix column +let valuesMatrixCol = function(messages, row, c, address) { + this.messages = messages; + this.address = address; + this.index = c; + this.row = row; +}; + +// initialize a matrix with empty data with size rows and columns +valuesMatrix.prototype.zeroInit = function (size) { + for (let r=0; r<size; r++) { + this.addRow(); + } +}; + +valuesMatrix.prototype.setRowCol = function (r, c, ingress, egress, address, value) { + this.rows[r].ingress = ingress; + this.rows[r].egress = egress; + this.rows[r].cols[c].messages = value; + this.rows[r].cols[c].address = address; +}; + +valuesMatrix.prototype.setColMessages = function (r, c, messages) { + this.rows[r].cols[c].messages = messages; +}; + +// return true if any of the matrix cells have messages +valuesMatrix.prototype.hasValues = function () { + return this.rows.some(function (row) { + return row.cols.some(function (col) { + return col.messages > MIN_CHORD_THRESHOLD; + }); + }); +}; + +// extract a square matrix with just the values from the object matrix +valuesMatrix.prototype.matrixMessages = function () { + let m = emptyMatrix(this.rows.length); + this.rows.forEach( function (row, r) { + row.cols.forEach( function (col, c) { + m[r][c] = col.messages; + }); + }); + return m; +}; + +valuesMatrix.prototype.getGroupBy = function () { + if (!this.aggregate && this.rows.length) { + let groups = []; + let lastName = this.rows[0].egress, groupIndex = 0; + this.rows.forEach( function (row) { + if (row.egress !== lastName) { + groupIndex++; + lastName = row.egress; + } + groups.push(groupIndex); + }); + return groups; + } + else + return d3.range(this.rows.length); +}; + +valuesMatrix.prototype.chordName = function (i, ingress) { + if (this.aggregate) + return this.rows[i].chordName; + return (ingress ? this.rows[i].ingress : this.rows[i].egress); +}; +valuesMatrix.prototype.routerName = function (i) { + if (this.aggregate) + return this.rows[i].chordName; + return getAttribute(this, 'egress', i); +}; +valuesMatrix.prototype.getEgress = function (i) { + return getAttribute(this, 'egress', i); +}; +valuesMatrix.prototype.getIngress = function (i) { + return getAttribute(this, 'ingress', i); +}; +valuesMatrix.prototype.getAddress = function (r, c) { + return this.rows[r].cols[c].address; +}; +valuesMatrix.prototype.getAddresses = function (r) { + let addresses = {}; + this.rows[r].cols.forEach( function (c) { + if (c.address && c.messages) + addresses[c.address] = true; + }); + return Object.keys(addresses); +}; +let getAttribute = function (self, attr, i) { + if (self.aggregate) + return self.rows[i][attr]; + return self.rows[self.getGroupBy().indexOf(i)][attr]; +}; +valuesMatrix.prototype.addRow = function (chordName, ingress, egress, address) { + let rowIndex = this.rows.length; + let newRow = new valuesMatrixRow(rowIndex, chordName, ingress, egress); + this.rows.push(newRow); + // add new column to all rows + for (let r=0; r<=rowIndex; r++) { + this.rows[r].addCol(0, address); + } + return rowIndex; +}; +valuesMatrix.prototype.indexOf = function (chordName) { + return this.rows.findIndex( function (row) { + return row.chordName === chordName; + }); +}; +valuesMatrix.prototype.addValue = function (r, c, value) { + this.rows[r].cols[c].addMessages(value.messages); + this.rows[r].cols[c].setAddress(value.address); +}; +valuesMatrixRow.prototype.addCol = function (messages, address) { + this.cols.push(new valuesMatrixCol(messages, this, this.cols.length, address)); +}; +valuesMatrixCol.prototype.addMessages = function (messages) { + if (!(this.messages === MIN_CHORD_THRESHOLD && messages === MIN_CHORD_THRESHOLD)) + this.messages += messages; +}; +valuesMatrixCol.prototype.setAddress = function (address) { + this.address = address; +}; +valuesMatrix.prototype.getChordList = function () { + return this.rows.map( function (row) { + return row.chordName; + }); +}; +valuesMatrix.prototype.sorted = function () { + let newChordList = this.getChordList(); + newChordList.sort(); + let m = new valuesMatrix(this.aggregate); + m.zeroInit(this.rows.length); + this.rows.forEach( function (row) { + let chordName = row.chordName; + row.cols.forEach( function (col, c) { + let newRow = newChordList.indexOf(chordName); + let newCol = newChordList.indexOf(this.rows[c].chordName); + m.rows[newRow].chordName = chordName; + m.rows[newRow].ingress = row.ingress; + m.rows[newRow].egress = row.egress; + m.rows[newRow].cols[newCol].messages = col.messages; + m.rows[newRow].cols[newCol].address = col.address; + }.bind(this)); + }.bind(this)); + return m; +}; + +// private helper function +let emptyMatrix = function (size) { + let matrix = []; + for(let i=0; i<size; i++) { + matrix[i] = []; + for(let j=0; j<size; j++) { + matrix[i][j] = 0; + } + } + return matrix; +}; http://git-wip-us.apache.org/repos/asf/qpid-dispatch/blob/55e08784/console/stand-alone/plugin/js/chord/qdrChord.js ---------------------------------------------------------------------- diff --git a/console/stand-alone/plugin/js/chord/qdrChord.js b/console/stand-alone/plugin/js/chord/qdrChord.js new file mode 100644 index 0000000..c5f7417 --- /dev/null +++ b/console/stand-alone/plugin/js/chord/qdrChord.js @@ -0,0 +1,823 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one +or more contributor license agreements. See the NOTICE file +distributed with this work for additional information +regarding copyright ownership. The ASF licenses this file +to you 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. +*/ +'use strict'; +/* global angular d3 separateAddresses aggregateAddresses ChordData qdrRibbon qrdlayoutChord */ + +var QDR = (function (QDR) { + QDR.module.controller('QDR.ChordController', ['$scope', 'QDRService', '$location', '$timeout', '$sce', function($scope, QDRService, $location, $timeout, $sce) { + + // if we get here and there is no connection, redirect to the connect page and then + // return here once we are connected + if (!QDRService.management.connection.is_connected()) { + QDR.redirectWhenConnected($location, 'chord'); + return; + } + + const CHORDOPTIONSKEY = 'chordOptions'; + const CHORDFILTERKEY = 'chordFilter'; + const DOUGHNUT = '#chord svg .empty'; + const ERROR_RENDERING = 'Error while rendering '; + const ARCPADDING = .06; + + // flag to show/hide the router section of the legend + $scope.noValues = true; + // state of the option checkboxes + $scope.legendOptions = angular.fromJson(localStorage[CHORDOPTIONSKEY]) || {isRate: false, byAddress: false}; + // remember which addresses were last selected and restore them when page loads + let excludedAddresses = angular.fromJson(localStorage[CHORDFILTERKEY]) || []; + // colors for the legend and the diagram + $scope.chordColors = {}; + $scope.arcColors = {}; + + // get notified when the byAddress checkbox is toggled + let switchedByAddress = false; + $scope.$watch('legendOptions.byAddress', function (newValue, oldValue) { + if (newValue !== oldValue) { + d3.select('#legend') + .classed('byAddress', newValue); + chordData.setConverter($scope.legendOptions.byAddress ? separateAddresses: aggregateAddresses); + switchedByAddress = true; + updateNow(); + localStorage[CHORDOPTIONSKEY] = JSON.stringify($scope.legendOptions); + } + }); + // get notified when the 'by rate' checkbox is toggled + $scope.$watch('legendOptions.isRate', function (n, o) { + if (n !== o) { + chordData.setRate($scope.legendOptions.isRate); + + let doughnut = d3.select(DOUGHNUT); + if (!doughnut.empty()) { + fadeDoughnut(); + } + updateNow(); + + localStorage[CHORDOPTIONSKEY] = JSON.stringify($scope.legendOptions); + } + }); + $scope.arcColorsEmpty = function () { + return Object.keys($scope.arcColors).length === 0; + }; + + // event notification that an address checkbox has changed + $scope.addressFilterChanged = function () { + fadeDoughnut(); + + excludedAddresses = []; + for (let address in $scope.addresses) { + if (!$scope.addresses[address]) + excludedAddresses.push(address); + } + localStorage[CHORDFILTERKEY] = JSON.stringify(excludedAddresses); + if (chordData) + chordData.setFilter(excludedAddresses); + updateNow(); + }; + + // called by angular when mouse enters one of the address legends + $scope.enterLegend = function (addr) { + if (!$scope.legendOptions.byAddress) + return; + // fade all chords that don't have this address + let indexes = []; + chordData.last_matrix.rows.forEach( function (row, r) { + let addresses = chordData.last_matrix.getAddresses(r); + if (addresses.indexOf(addr) >= 0) + indexes.push(r); + }); + d3.selectAll('path.chord').classed('fade', function(p) { + return indexes.indexOf(p.source.orgindex) < 0 && indexes.indexOf(p.target.orgindex) < 0; + }); + }; + + // called by angular when mouse enters one of the router legends + $scope.enterRouter = function (router) { + let indexes = []; + // fade all chords that are not associated with this router + let agg = chordData.last_matrix.aggregate; + chordData.last_matrix.rows.forEach( function (row, r) { + if (agg) { + if (row.chordName === router) + indexes.push(r); + } else { + if (row.ingress === router || row.egress === router) + indexes.push(r); + } + }); + d3.selectAll('path.chord').classed('fade', function(p) { + return indexes.indexOf(p.source.orgindex) < 0 && indexes.indexOf(p.target.orgindex) < 0; + }); + }; + $scope.leaveLegend = function () { + showAllChords(); + }; + // clicked on the address name. toggle the address checkbox + $scope.addressClick = function (address) { + $scope.addresses[address] = !$scope.addresses[address]; + $scope.addressFilterChanged(); + }; + + // fade out the empty circle that is shown when there is no traffic + let fadeDoughnut = function () { + d3.select(DOUGHNUT) + .transition() + .duration(200) + .attr('opacity', 0) + .remove(); + }; + + // create an object that will be used to fetch the data + let chordData = new ChordData(QDRService, + $scope.legendOptions.isRate, + $scope.legendOptions.byAddress ? separateAddresses: aggregateAddresses); + chordData.setFilter(excludedAddresses); + + // get the data now in response to a user event (as opposed to periodically) + let updateNow = function () { + clearInterval(interval); + chordData.getMatrix().then(render, function (e) { console.log(ERROR_RENDERING + e);}); + interval = setInterval(doUpdate, transitionDuration); + }; + + // size the diagram based on the browser window size + let getRadius = function () { + let w = window, + d = document, + e = d.documentElement, + b = d.getElementsByTagName('body')[0], + x = w.innerWidth || e.clientWidth || b.clientWidth, + y = w.innerHeight|| e.clientHeight|| b.clientHeight; + return Math.max(Math.floor((Math.min(x, y) * 0.9) / 2), 300); + }; + + // diagram sizes that change when browser is resized + let outerRadius, innerRadius, textRadius; + let setSizes = function () { + // size of circle + text + outerRadius = getRadius(); + // size of chords + innerRadius = outerRadius - 130; + // arc ring around chords + textRadius = Math.min(innerRadius * 1.1, innerRadius + 15); + }; + setSizes(); + + // TODO: handle window resizes + //let updateWindow = function () { + //setSizes(); + //startOver(); + //}; + //d3.select(window).on('resize.updatesvg', updateWindow); + let offBy = 6; + let windowResized = function () { + let legendPos = $('#legend').position(); + let switches = $('#switches'); + let outerWidth = switches.outerWidth(); + switches.css({left: (legendPos.left - outerWidth - offBy), opacity: 1}); + offBy = 0; + }; + window.addEventListener('resize', function () { + windowResized() + setTimeout(windowResized, 1); + }); + $().ready(windowResized); + + // used for animation duration and the data refresh interval + let transitionDuration = 1000; + // format with commas + let formatNumber = d3.format(',.1f'); + + // colors + let colorGen = d3.scale.category20(); + // The colorGen funtion is not random access. + // To get the correct color[19] you first have to get all previous colors + // I suspect some caching is going on in d3 + for (let i=0; i<20; i++) { + colorGen(i); + } + // arc colors are taken from every other color starting at 0 + let getArcColor = function (n) { + if (!(n in $scope.arcColors)) { + let ci = Object.keys($scope.arcColors).length * 2; + $scope.arcColors[n] = colorGen(ci); + } + return $scope.arcColors[n]; + }; + // chord colors are taken from every other color starting at 19 and going backwards + let getChordColor = function (n) { + if (!(n in $scope.chordColors)) { + let ci = 19 - Object.keys($scope.chordColors).length * 2; + let c = colorGen(ci); + $scope.chordColors[n] = c; + } + return $scope.chordColors[n]; + }; + // return the color associated with a router + let fillArc = function (matrixValues, row) { + let router = matrixValues.routerName(row); + return getArcColor(router); + }; + // return the color associated with a chord. + // if viewing by address, the color will be the address color. + // if viewing aggregate, the color will be the router color of the largest chord ending + let fillChord = function (matrixValues, d) { + // aggregate + if (matrixValues.aggregate) { + return fillArc(matrixValues, d.source.index); + } + // by address + let addr = matrixValues.getAddress(d.source.orgindex, d.source.orgsubindex); + return getChordColor(addr); + }; + + // keep track of previous chords so we can animate to the new values + let last_chord, last_labels; + + // global pointer to the diagram + let svg; + + // called once when the page loads and again + // whenever the number of routers that have egressed messages changes + let initSvg = function () { + d3.select('#chord svg').remove(); + + svg = d3.select('#chord').append('svg') + .attr('width', outerRadius * 2) + .attr('height', outerRadius * 2) + .append('g') + .attr('id', 'circle') + .attr('transform', 'translate(' + outerRadius + ',' + outerRadius + ')'); + + // mouseover target for when the mouse leaves the diagram + svg.append('circle') + .attr('r', innerRadius * 2) + .on('mouseover', showAllChords); + + // background circle. will only get a mouseover event if the mouse is between chords + svg.append('circle') + .attr('r', innerRadius) + .on('mouseover', function() { d3.event.stopPropagation(); }); + + svg = svg.append('g') + .attr('class', 'chart-container'); + }; + initSvg(); + + let emptyCircle = function () { + $scope.noValues = false; + d3.select(DOUGHNUT).remove(); + + let arc = d3.svg.arc() + .innerRadius(innerRadius) + .outerRadius(textRadius) + .startAngle(0) + .endAngle(Math.PI * 2); + + d3.select('#circle').append('path') + .attr('class', 'empty') + .attr('d', arc); + }; + + let genArcColors = function () { + //$scope.arcColors = {}; + let routers = chordData.getRouters(); + routers.forEach( function (router) { + getArcColor(router); + }); + }; + let genChordColors = function () { + $scope.chordColors = {}; + if ($scope.legendOptions.byAddress) { + Object.keys($scope.addresses).forEach( function (address) { + getChordColor(address); + }); + } + }; + let chordKey = function (d, matrix) { + // sort so that if the soure and target are flipped, the chord doesn't + // get destroyed and recreated + return getRouterNames(d, matrix).sort().join('-'); + }; + let popoverChord = null; + let popoverArc = null; + + let getRouterNames = function (d, matrix) { + let egress, ingress, address = ''; + // for arcs d will have an index, for chords d will have a source.index and target.index + let eindex = angular.isDefined(d.index) ? d.index : d.source.index; + let iindex = angular.isDefined(d.index) ? d.index : d.source.subindex; + if (matrix.aggregate) { + egress = matrix.rows[eindex].chordName; + ingress = matrix.rows[iindex].chordName; + } else { + egress = matrix.routerName(eindex); + ingress = matrix.routerName(iindex); + // if a chord + if (d.source) { + address = matrix.getAddress(d.source.orgindex, d.source.orgsubindex); + } + } + return [ingress, egress, address]; + }; + // popup title when mouse is over a chord + // shows the address, from and to routers, and the values + let chordTitle = function (d, matrix) { + let rinfo = getRouterNames(d, matrix); + let from = rinfo[0], to = rinfo[1], address = rinfo[2]; + if (!matrix.aggregate) { + address += '<br/>'; + } + let title = address + from + + ' â ' + to + + ': ' + formatNumber(d.source.value); + if (d.target.value > 0 && to !== from) { + title += ('<br/>' + to + + ' â ' + from + + ': ' + formatNumber(d.target.value)); + } + return title; + }; + let arcTitle = function (d, matrix) { + let egress, value = 0; + if (matrix.aggregate) { + egress = matrix.rows[d.index].chordName; + value = d.value; + } + else { + egress = matrix.routerName(d.index); + value = d.value; + } + return egress + ': ' + formatNumber(value); + }; + + let decorateChordData = function (rechord, matrix) { + let data = rechord.chords(); + data.forEach( function (d, i) { + d.key = chordKey(d, matrix, false); + d.orgIndex = i; + d.color = fillChord(matrix, d); + }); + return data; + }; + + let decorateArcData = function (fn, matrix) { + let fixedGroups = fn(); + fixedGroups.forEach( function (fg) { + fg.orgIndex = fg.index; + fg.angle = (fg.endAngle + fg.startAngle)/2; + fg.key = matrix.routerName(fg.index); + fg.components = [fg.index]; + fg.router = matrix.aggregate ? fg.key : matrix.getEgress(fg.index); + fg.color = getArcColor(fg.router); + }); + return fixedGroups; + }; + + let theyveBeenWarned = false; + // create and/or update the chord diagram + function render(matrix) { + $scope.addresses = chordData.getAddresses(); + // populate the arcColors object with a color for each router + genArcColors(); + genChordColors(); + + // if all the addresses are excluded, update the message + let addressLen = Object.keys($scope.addresses).length; + $scope.allAddressesFiltered = false; + if (addressLen > 0 && excludedAddresses.length === addressLen) { + $scope.allAddressesFiltered = true; + } + + $scope.noValues = false; + let matrixMessages, duration = transitionDuration; + + // if there is no data, show an empty circle and a message + if (!matrix.hasValues()) { + $timeout( function () { + $scope.noValues = $scope.arcColors.length === 0; + if (!theyveBeenWarned) { + theyveBeenWarned = true; + let msg = 'There is no message traffic'; + if (addressLen !== 0) + msg += ' for the selected addresses'; + $.notify($('#noTraffic'), msg, {clickToHide: false, autoHide: false, arrowShow: false, className: 'Warning'}); + } + }); + emptyCircle(); + matrixMessages = []; + } else { + matrixMessages = matrix.matrixMessages(); + $('.notifyjs-wrapper').hide(); + theyveBeenWarned = false; + fadeDoughnut(); + } + + // create a new chord layout so we can animate between the last one and this one + let groupBy = matrix.getGroupBy(); + let rechord = qrdlayoutChord().padding(ARCPADDING).groupBy(groupBy).matrix(matrixMessages); + + // The chord layout has a function named .groups() that returns the + // data for the arcs. We decorate this data with a unique key. + rechord.arcData = decorateArcData(rechord.groups, matrix); + + // join the decorated data with a d3 selection + let arcsGroup = svg.selectAll('g.arc') + .data(rechord.arcData, function (d) {return d.key;}); + + // get a d3 selection of all the new arcs that have been added + let newArcs = arcsGroup.enter().append('svg:g') + .attr('class', 'arc'); + + // each new arc is an svg:path that has a fixed color + newArcs.append('svg:path') + .style('fill', function(d) { return d.color; }) + .style('stroke', function(d) { return d.color; }); + + newArcs.append('svg:text') + .attr('dy', '.35em') + .text(function (d) { + return d.router; + }); + + // attach event listeners to all arcs (new or old) + arcsGroup + .on('mouseover', mouseoverArc) + .on('mousemove', function (d) { + popoverArc = d; + let top = $('#chord').offset().top - 5; + $timeout(function () { + $scope.trustedpopoverContent = $sce.trustAsHtml(arcTitle(d, matrix)); + }); + d3.select('#popover-div') + .style('display', 'block') + .style('left', (d3.event.pageX+5)+'px') + .style('top', (d3.event.pageY-top)+'px'); + }) + .on('mouseout', function () { + popoverArc = null; + d3.select('#popover-div') + .style('display', 'none'); + }); + + // animate the arcs path to it's new location + arcsGroup.select('path') + .transition() + .duration(duration) + //.ease('linear') + .attrTween('d', arcTween(last_chord)); + arcsGroup.select('text') + .attr('text-anchor', function (d) { + return d.angle > Math.PI ? 'end' : 'begin'; + }) + .transition() + .duration(duration) + .attrTween('transform', tickTween(last_labels)); + + // check if the mouse is hovering over an arc. if so, update the tooltip + arcsGroup + .each(function(d) { + if (popoverArc && popoverArc.index === d.index) { + $scope.trustedpopoverContent = $sce.trustAsHtml(arcTitle(d, matrix)); + } + }); + + // animate the removal of any arcs that went away + let exitingArcs = arcsGroup.exit(); + + exitingArcs.selectAll('text') + .transition() + .duration(duration/2) + .attrTween('opacity', function () {return function (t) {return 1 - t;};}); + + exitingArcs.selectAll('path') + .transition() + .duration(duration/2) + .attrTween('d', arcTweenExit) + .each('end', function (d) {d3.select(this).node().parentNode.remove();}); + + // decorate the chord layout's .chord() data with key, color, and orgIndex + rechord.chordData = decorateChordData(rechord, matrix); + let chordPaths = svg.selectAll('path.chord') + .data(rechord.chordData, function (d) { return d.key;}); + + // new chords are paths + chordPaths.enter().append('path') + .attr('class', 'chord'); + + if (!switchedByAddress) { + // do multiple concurrent tweens on the chords + chordPaths + .call(tweenChordEnds, duration, last_chord) + .call(tweenChordColor, duration, last_chord, 'stroke') + .call(tweenChordColor, duration, last_chord, 'fill'); + } else { + // switchByAddress is only true when we have new chords + chordPaths + .attr('d', function (d) {return chordReference(d);}) + .attr('stroke', function (d) {return d3.rgb(d.color).darker(1);}) + .attr('fill', function (d) {return d.color;}) + .attr('opacity', 1e-6) + .transition() + .duration(duration/2) + .attr('opacity', .67); + } + + // if the mouse is hovering over a chord, update it's tooltip + chordPaths + .each(function(d) { + if (popoverChord && + popoverChord.source.orgindex === d.source.orgindex && + popoverChord.source.orgsubindex === d.source.orgsubindex) { + $scope.trustedpopoverContent = $sce.trustAsHtml(chordTitle(d, matrix)); + } + }); + + // attach mouse event handlers to the chords + chordPaths + .on('mouseover', mouseoverChord) + .on('mousemove', function (d) { + popoverChord = d; + let top = $('#chord').offset().top - 5; + $timeout(function () { + $scope.trustedpopoverContent = $sce.trustAsHtml(chordTitle(d, matrix)); + }); + d3.select('#popover-div') + .style('display', 'block') + .style('left', (d3.event.pageX+5)+'px') + .style('top', (d3.event.pageY-top)+'px'); + }) + .on('mouseout', function () { + popoverChord = null; + d3.select('#popover-div') + .style('display', 'none'); + }); + + let exitingChords = chordPaths.exit() + .attr('class', 'exiting-chord'); + + if (!switchedByAddress) { + // shrink chords to their center point upon removal + exitingChords + .transition() + .duration(duration/2) + .attrTween('d', chordTweenExit) + .remove(); + } else { + // just fade them out if we are switching between byAddress and aggregate + exitingChords + .transition() + .duration(duration/2) + .ease('linear') + .attr('opacity', 1e-6) + .remove(); + } + + // keep track of this layout so we can animate from this layout to the next layout + last_chord = rechord; + last_labels = last_chord.arcData; + switchedByAddress = false; + + // update the UI for any $scope variables that changed + if(!$scope.$$phase) $scope.$apply(); + } + + // used to transition chords along a circular path instead of linear. + // qdrRibbon is a replacement for d3.svg.chord() that avoids the twists + let chordReference = qdrRibbon().radius(innerRadius); + + // used to transition arcs along a curcular path instead of linear + let arcReference = d3.svg.arc() + .startAngle(function(d) { return d.startAngle; }) + .endAngle(function(d) { return d.endAngle; }) + .innerRadius(innerRadius) + .outerRadius(textRadius); + + // animate the disappearance of an arc by shrinking it to its center point + function arcTweenExit(d) { + let angle = (d.startAngle+d.endAngle)/2; + let to = {startAngle: angle, endAngle: angle, value: 0}; + let from = {startAngle: d.startAngle, endAngle: d.endAngle, value: d.value}; + let tween = d3.interpolate(from, to); + return function (t) { + return arcReference( tween(t) ); + }; + } + // animate the exit of a chord by shrinking it to the center points of its arcs + function chordTweenExit(d) { + let angle = function (d) { + return (d.startAngle + d.endAngle) / 2; + }; + let from = {source: {startAngle: d.source.startAngle, endAngle: d.source.endAngle}, + target: {startAngle: d.target.startAngle, endAngle: d.target.endAngle}}; + let to = {source: {startAngle: angle(d.source), endAngle: angle(d.source)}, + target: {startAngle: angle(d.target), endAngle: angle(d.target)}}; + let tween = d3.interpolate(from, to); + + return function (t) { + return chordReference( tween(t) ); + }; + } + + // Animate an arc from its old location to its new. + // If the arc is new, grow the arc from its startAngle to its full size + function arcTween(oldLayout) { + var oldGroups = {}; + if (oldLayout) { + oldLayout.arcData.forEach( function(groupData) { + oldGroups[ groupData.index ] = groupData; + }); + } + return function (d) { + var tween; + var old = oldGroups[d.index]; + if (old) { //there's a matching old group + tween = d3.interpolate(old, d); + } + else { + //create a zero-width arc object + let mid = (d.startAngle + d.endAngle) / 2; + var emptyArc = {startAngle: mid, endAngle: mid}; + tween = d3.interpolate(emptyArc, d); + } + + return function (t) { + return arcReference( tween(t) ); + }; + }; + } + + // animate all the chords to their new positions + function tweenChordEnds(chords, duration, last_layout) { + let oldChords = {}; + if (last_layout) { + last_layout.chordData.forEach( function(d) { + oldChords[ d.key ] = d; + }); + } + chords.each(function (d) { + let chord = d3.select(this); + // This version of d3 doesn't support multiple concurrent transitions on the same selection. + // Since we want to animate the chord's path as well as its color, we create a dummy selection + // and use that to directly transition each chord + d3.select({}) + .transition() + .duration(duration) + .tween('attr:d', function () { + let old = oldChords[ d.key ], interpolate; + if (old) { + // avoid swapping the end of cords where the source/target have been flipped + // Note: the chord's colors will be swapped in a different tween + if (old.source.index === d.target.index && + old.source.subindex === d.target.subindex) { + let s = old.source; + old.source = old.target; + old.target = s; + } + } else { + // there was no old chord so make a fake one + let midStart = (d.source.startAngle + d.source.endAngle) / 2; + let midEnd = (d.target.startAngle + d.target.endAngle) / 2; + old = { + source: { startAngle: midStart, + endAngle: midStart}, + target: { startAngle: midEnd, + endAngle: midEnd} + }; + } + interpolate = d3.interpolate(old, d); + return function(t) { + chord.attr('d', chordReference(interpolate(t))); + }; + }); + }); + } + + // animate a chord to its new color + function tweenChordColor(chords, duration, last_layout, style) { + let oldChords = {}; + if (last_layout) { + last_layout.chordData.forEach( function(d) { + oldChords[ d.key ] = d; + }); + } + chords.each(function (d) { + let chord = d3.select(this); + d3.select({}) + .transition() + .duration(duration) + .tween('style:'+style, function () { + let old = oldChords[ d.key ], interpolate; + let oldColor = '#CCCCCC', newColor = d.color; + if (old) { + oldColor = old.color; + } + if (style === 'stroke') { + oldColor = d3.rgb(oldColor).darker(1); + newColor = d3.rgb(newColor).darker(1); + } + interpolate = d3.interpolate(oldColor, newColor); + return function(t) { + chord.style(style, interpolate(t)); + }; + }); + }); + } + + // animate the arc labels to their new locations + function tickTween(oldArcs) { + var oldTicks = {}; + if (oldArcs) { + oldArcs.forEach( function(d) { + oldTicks[ d.key ] = d; + }); + } + let angle = function (d) { + return (d.startAngle + d.endAngle) / 2; + }; + return function (d) { + var tween; + var old = oldTicks[d.key]; + let start = angle(d); + let startTranslate = textRadius - 40; + let orient = d.angle > Math.PI ? 'rotate(180)' : ''; + if (old) { //there's a matching old group + start = angle(old); + startTranslate = textRadius; + } + tween = d3.interpolateNumber(start, angle(d)); + let same = start === angle(d); + let tsame = startTranslate === textRadius; + + let transTween = d3.interpolateNumber(startTranslate, textRadius + 10); + + return function (t) { + let rot = same ? start : tween(t); + if (isNaN(rot)) + rot = 0; + let tra = tsame ? (textRadius + 10) : transTween(t); + return 'rotate(' + (rot * 180 / Math.PI - 90) + ') ' + + 'translate(' + tra + ',0)' + orient; + }; + }; + } + + // fade all chords that don't belong to the given arc index + function mouseoverArc(d) { + d3.selectAll('path.chord').classed('fade', function(p) { + return d.index !== p.source.index && d.index !== p.target.index; + }); + } + + // fade all chords except the given one + function mouseoverChord(d) { + svg.selectAll('path.chord').classed('fade', function(p) { + return !(p.source.orgindex === d.source.orgindex && p.target.orgindex === d.target.orgindex); + }); + } + + function showAllChords() { + svg.selectAll('path.chord').classed('fade', false); + } + + // when the page is exited + $scope.$on('$destroy', function() { + // stop updated the data + clearInterval(interval); + // clean up memory associated with the svg + d3.select('#chord').remove(); + d3.select(window).on('resize.updatesvg', null); + window.removeEventListener('resize', windowResized); + }); + + // get the raw data and render the svg + chordData.getMatrix().then(render, function (e) { + console.log(ERROR_RENDERING + e); + }); + // called periodically to refresh the data + function doUpdate() { + chordData.getMatrix().then(render, function (e) { + console.log(ERROR_RENDERING + e); + }); + } + let interval = setInterval(doUpdate, transitionDuration); + + }]); + return QDR; + +} (QDR || {})); http://git-wip-us.apache.org/repos/asf/qpid-dispatch/blob/55e08784/console/stand-alone/plugin/js/chord/ribbon/README.md ---------------------------------------------------------------------- diff --git a/console/stand-alone/plugin/js/chord/ribbon/README.md b/console/stand-alone/plugin/js/chord/ribbon/README.md new file mode 100644 index 0000000..18999c4 --- /dev/null +++ b/console/stand-alone/plugin/js/chord/ribbon/README.md @@ -0,0 +1,22 @@ + + This is a replacement for the d3.svg.chord() ribbon generator. + The native d3 implementation is efficient, but its chords can become 'twisted' + in certain curcumatances. + + A chord has up to 4 components: + 1. A beginning arc along the edge of a circle + 2. A quadratic bezier curve across the circle to the start of another arc + 3. A 2nd arc along the edge of the circle + 4. A quadratic bezier curve to the start of the 1st arc + + Components 2 and 3 are dropped if the chord has only one endpoint. + + The problem arises when the arcs are very close to each other and one arc is significantly + larger than the other. The inner bezier curve connecting the arcs extends towards the center + of the circle. The outer bezier curve connecting the outer ends of the arc crosses the inner + bezier curve causing the chords to look twisted. + + The solution implemented here is to adjust the inner bezier curve to not extend into the circle very far. + That is done by changing its control point. Instead of the control point being at the center + of the circle, it is moved towards the edge of the circle in the direction of the midpoint of + the bezier curve's end points. http://git-wip-us.apache.org/repos/asf/qpid-dispatch/blob/55e08784/console/stand-alone/plugin/js/chord/ribbon/ribbon.js ---------------------------------------------------------------------- diff --git a/console/stand-alone/plugin/js/chord/ribbon/ribbon.js b/console/stand-alone/plugin/js/chord/ribbon/ribbon.js new file mode 100644 index 0000000..604fa1e --- /dev/null +++ b/console/stand-alone/plugin/js/chord/ribbon/ribbon.js @@ -0,0 +1,165 @@ +/* +Licensed to the Apache Software Foundation (ASF) under one +or more contributor license agreements. See the NOTICE file +distributed with this work for additional information +regarding copyright ownership. The ASF licenses this file +to you 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. +*/ +'use strict'; +/* global d3 */ + +const halfPI = Math.PI / 2.0; +const twoPI = Math.PI * 2.0; + +// These are scales to interpolate how the bezier control point should be adjusted. +// These numbers were determined emperically by adjusting a chord and discovering +// the relationship between the width of the inner bezier and the lengths of the arcs. +// If we were just drawing the chord diagram once, we wouldn't need to use scales. +// But since we are animating chords, we need to smoothly chnage the control point from +// [0, 0] to 1/2 way to the center of the bezier curve. +const dom = [.06, .98, Math.PI]; +const ys = d3.scale.linear().domain(dom).range([.18, 0, 0]); +const x0s = d3.scale.linear().domain(dom).range([.03, .24, .24]); +const x1s = d3.scale.linear().domain(dom).range([.24, .6, .6]); +const x2s = d3.scale.linear().domain(dom).range([1.32, .8, .8]); +const x3s = d3.scale.linear().domain(dom).range([3, 2, 2]); + +function qdrRibbon() { // eslint-disable-line no-unused-vars + var r = 200; // random default. this will be set later + + // This is the function that gets called to produce a path for a chord. + // The path should end up looking like + // M[start point]A[arc options][arc end point]Q[control point][end points]A[arc options][arc end point]Q[control point][end points]Z + var ribbon = function (d) { + let sa0 = d.source.startAngle - halfPI, + sa1 = d.source.endAngle - halfPI, + ta0 = d.target.startAngle - halfPI, + ta1 = d.target.endAngle - halfPI; + + // The control points for the bezier curves + let cp1 = [0, 0]; + let cp2 = [0, 0]; + // the span of the two arcs + let arc1 = Math.abs(sa0 - sa1); + let arc2 = Math.abs(ta0 - ta1); + let largeArc = Math.max(arc1, arc2); + let smallArc = Math.min(arc1, arc2); + // the gaps between the arcs + let gap1 = Math.abs(sa1 - ta0); + if (gap1 > Math.PI) gap1 = twoPI - gap1; + let gap2 = Math.abs(sa0 - ta1); + if (gap2 > Math.PI) gap2 = twoPI - gap2; + let sgap = Math.min(gap1, gap2); + + // if the bezier curves intersect, ratiocp will be > 0 + let ratiocp = cpRatio(sgap, largeArc, smallArc); + + // x, y points for the start and end of the arcs + let s0x = r * Math.cos(sa0), + s0y = r * Math.sin(sa0), + t0x = r * Math.cos(ta0), + t0y = r * Math.sin(ta0); + + if (ratiocp > 0) { + // determine which control point to calculate + if ((Math.abs(gap1-gap2) < 1e-2) || (gap1 < gap2)) { + let s1x = r * Math.cos(sa1), + s1y = r * Math.sin(sa1); + cp1 = [ratiocp*(s1x + t0x)/2, ratiocp*(s1y + t0y)/2]; + } else { + let t1x = r * Math.cos(ta1), + t1y = r * Math.sin(ta1); + cp2 = [ratiocp*(t1x + s0x)/2, ratiocp*(t1y + s0y)/2]; + } + } + + // construct the path using the control points + let path = d3.path(); + path.moveTo(s0x, s0y); + path.arc(0, 0, r, sa0, sa1); + if (sa0 != ta0 || sa1 !== ta1) { + path.quadraticCurveTo(cp1[0], cp1[1], t0x, t0y); + path.arc(0, 0, r, ta0, ta1); + } + path.quadraticCurveTo(cp2[0], cp2[1], s0x, s0y); + path.closePath(); + return path + ''; + + }; + ribbon.radius = function (radius) { + if (!arguments.length) return r; + r = radius; + return ribbon; + }; + return ribbon; +} + +let sqr = function (n) { return n * n; }; +let dist = function (p1x, p1y, p2x, p2y) { return sqr(p1x - p2x) + sqr(p1y - p2y);}; +// distance from a point to a line segment +let distToLine = function (vx, vy, wx, wy, px, py) { + let vlen = dist(vx, vy, wx, wy); + if (vlen === 0) return dist(px, py, vx, vy); + var t = ((px-vx)*(wx-vx) + (py-vy)*(wy-vy)) / vlen; + t = Math.max(0, Math.min(1, t)); // clamp t to between 0 and 1 + return Math.sqrt(dist(px, py, vx + t*(wx-vx), vy + t*(wy-vy))); +}; + +// See if x, y is contained in trapezoid. +// gap is the smallest gap in the chord +// x is the size of the longest arc +// y is the size of the smallest arc +// the trapezoid is defined by [x0, 0] [x1, top] [x2, top] [x3, 0] +// these points are determined by the gap +let cpRatio = function (gap, x, y) { + let top = ys(gap); + if (y >= top) + return 0; + + // get the xpoints of the trapezoid + let x0 = x0s(gap); + if (x <= x0) + return 0; + let x3 = x3s(gap); + if (x > x3) + return 0; + + let x1 = x1s(gap); + let x2 = x2s(gap); + + // see if the point is to the right of (inside) the leftmost diagonal + // compute the outer product of the left diagonal and the point + let op = (x-x0)*top - y*(x1-x0); + if (op <= 0) + return 0; + // see if the point is to the left of the right diagonal + op = (x-x3)*top - y*(x2-x3); + if (op >= 0) + return 0; + + // the point is in the trapezoid. see how far in + let dist = 0; + if (x < x1) { + // left side. get distance to left diagonal + dist = distToLine(x0, 0, x1, top, x, y); + } else if (x > x2) { + // right side. get distance to right diagonal + dist = distToLine(x3, 0, x2, top, x, y); + } else { + // middle. get distance to top + dist = top - y; + } + let distScale = d3.scale.linear().domain([0, top/8, top/2, top]).range([0, .3, .4, .5]); + return distScale(dist); +}; http://git-wip-us.apache.org/repos/asf/qpid-dispatch/blob/55e08784/console/stand-alone/plugin/js/dispatchPlugin.js ---------------------------------------------------------------------- diff --git a/console/stand-alone/plugin/js/dispatchPlugin.js b/console/stand-alone/plugin/js/dispatchPlugin.js index f5df9c4..1eb320f 100644 --- a/console/stand-alone/plugin/js/dispatchPlugin.js +++ b/console/stand-alone/plugin/js/dispatchPlugin.js @@ -74,7 +74,7 @@ var QDR = (function(QDR) { * This plugin's angularjs module instance */ QDR.module = angular.module(QDR.pluginName, ['ngRoute', 'ngSanitize', 'ngResource', 'ui.bootstrap', - 'ui.grid', 'ui.grid.selection', 'ui.grid.autoResize', 'ui.grid.resizeColumns', 'ui.grid.saveState', 'ui.slider']); + 'ui.grid', 'ui.grid.selection', 'ui.grid.autoResize', 'ui.grid.resizeColumns', 'ui.grid.saveState', 'ui.slider', 'ui.checkbox']); // set up the routing for this plugin QDR.module.config(function($routeProvider) { @@ -97,6 +97,9 @@ var QDR = (function(QDR) { .when('/charts', { templateUrl: QDR.templatePath + 'qdrCharts.html' }) + .when('/chord', { + templateUrl: 'plugin/html/qdrChord.html' + }) .when('/connect', { templateUrl: QDR.templatePath + 'qdrConnect.html' }); http://git-wip-us.apache.org/repos/asf/qpid-dispatch/blob/55e08784/console/stand-alone/plugin/js/navbar.js ---------------------------------------------------------------------- diff --git a/console/stand-alone/plugin/js/navbar.js b/console/stand-alone/plugin/js/navbar.js index 24dcbc6..c127e85 100644 --- a/console/stand-alone/plugin/js/navbar.js +++ b/console/stand-alone/plugin/js/navbar.js @@ -61,6 +61,13 @@ var QDR = (function (QDR) { name: 'Charts' }, { + content: '<i class="chord-diagram"></i> Message Flow', + title: 'Chord chart', + isValid: function (QDRService) { return QDRService.management.connection.is_connected(); }, + href: '#/chord', + name: 'Message Flow' + }, + { content: '<i class="icon-align-left"></i> Schema', title: 'View dispatch schema', isValid: function (QDRService) { return QDRService.management.connection.is_connected(); }, --------------------------------------------------------------------- To unsubscribe, e-mail: commits-unsubscr...@qpid.apache.org For additional commands, e-mail: commits-h...@qpid.apache.org