console/react/src/chord/chordViewer.js (669 lines of code) (raw):

/* 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. */ import React, { Component } from "react"; import { TopologyView, TopologySideBar } from "@patternfly/react-topology"; import { getSizes } from "../topology/topoUtils"; import { separateAddresses, aggregateAddresses } from "./filters.js"; import { ChordData } from "./data.js"; import { qdrRibbon } from "./ribbon/ribbon.js"; import { qdrlayoutChord } from "./layout/layout.js"; import ChordToolbar from "./chordToolbar"; import QDRPopup from "../common/qdrPopup"; import * as d3 from "d3"; import PropTypes from "prop-types"; const CHORDOPTIONSKEY = "chordOptions"; const CHORDFILTERKEY = "chordFilter"; const DOUGHNUT = "#chord svg .empty"; const ERROR_RENDERING = "Error while rendering "; const ARCPADDING = 0.06; const SMALL_OFFSET = 210; const MIN_RADIUS = 200; const TRANSITION_DURATION = 1000; class ChordViewer extends Component { static propTypes = { service: PropTypes.object.isRequired }; constructor(props) { super(props); this.state = { addresses: {}, showPopup: false, popupContent: "", showEmpty: false, emptyText: "" }; let savedOptions = localStorage.getItem(CHORDFILTERKEY); this.excludedAddresses = savedOptions ? JSON.parse(savedOptions) : []; savedOptions = localStorage.getItem(CHORDOPTIONSKEY); this.state.legendOptions = savedOptions ? JSON.parse(savedOptions) : { isRate: true, byAddress: true }; this.chordData = new ChordData( this.props.service, this.state.legendOptions.isRate, this.state.legendOptions.byAddress ? separateAddresses : aggregateAddresses ); this.chordData.setFilter(this.excludedAddresses); this.chordColors = {}; this.outerRadius = null; this.innerRadius = null; this.textRadius = null; // used for animation duration and the data refresh interval // format with commas this.formatNumber = d3.format(",.1f"); // colors this.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++) { this.colorGen(i); } // keep track of previous chords so we can animate to the new values this.last_chord = null; this.last_labels = null; // global pointer to the diagram this.svg = null; this.popoverChord = null; this.popoverArc = null; this.theyveBeenWarned = false; this.arcColors = {}; } // called only once when the component is initialized componentDidMount() { this.init(); } componentWillUnmount() { // stop updated the data clearInterval(this.interval); // clean up memory associated with the svg //d3.select("#chord").remove(); //d3.select(window).on("resize.updatesvg", null); //window.removeEventListener("resize", this.windowResized); } init = () => { this.setSizes(); // used to transition chords along a circular path instead of linear. // qdrRibbon is a replacement for d3.svg.chord() that avoids the twists this.chordReference = qdrRibbon().radius(this.innerRadius); //this.chordReference = d3.svg.chord().radius(this.innerRadius); // used to transition arcs along a curcular path instead of linear this.arcReference = d3.svg .arc() .startAngle(d => { return d.startAngle; }) .endAngle(d => { return d.endAngle; }) .innerRadius(this.innerRadius) .outerRadius(this.textRadius); d3.select("#chord svg").remove(); let xtrans = this.outerRadius === MIN_RADIUS ? SMALL_OFFSET : this.outerRadius; this.svg = d3 .select("#chord") .append("svg") .attr("width", this.outerRadius * 2) .attr("height", this.outerRadius * 2) .attr("aria-label", "chord-svg") .append("g") .attr("id", "circle") .attr("transform", `translate(${xtrans},${this.outerRadius})`); // mouseover target for when the mouse leaves the diagram this.svg .append("circle") .attr("r", this.innerRadius * 2) .on("mouseover", this.showAllChords); // background circle. will only get a mouseover event if the mouse is between chords this.svg .append("circle") .attr("r", this.innerRadius) .on("mouseover", () => { d3.event.stopPropagation(); }); this.svg = this.svg.append("g").attr("class", "chart-container"); window.addEventListener("resize", () => { this.windowResized(); setTimeout(this.windowResized, 1); }); // get the raw data and render the svg this.chordData.getMatrix().then( matrix => { // now that we have the routers and addresses, move the legend this.windowResized(); //this.renderChord(matrix); }, e => { console.log(ERROR_RENDERING + e); } ); this.interval = setInterval(this.doUpdate, TRANSITION_DURATION); }; navbutton_toggle = () => { let legendPos = d3 .select("#legend") .node() .position(); if (legendPos.left === 0) setTimeout(this.windowResized, 10); }; // TODO: handle window resizes //let updateWindow = function () { //setSizes(); //startOver(); //}; //d3.select(window).on('resize.updatesvg', updateWindow); windowResized = () => {}; // size the diagram based on the browser window size getRadius = () => { const { width, height } = getSizes("chordContainer"); return Math.max(Math.floor((Math.min(width, height) * 0.9) / 2), MIN_RADIUS); }; // diagram sizes that change when browser is resized setSizes = () => { // size of circle + text this.outerRadius = this.getRadius(); // size of chords this.innerRadius = this.outerRadius - 130; // arc ring around chords this.textRadius = Math.min(this.innerRadius * 1.1, this.innerRadius + 15); }; // arc colors are taken from every other color starting at 0 getArcColor = n => { if (!(n in this.arcColors)) { let ci = Object.keys(this.arcColors).length * 2; this.arcColors[n] = this.colorGen(ci); } return this.arcColors[n]; }; // chord colors are taken from every other color starting at 19 and going backwards getChordColor = n => { if (!(n in this.chordColors)) { let ci = 19 - Object.keys(this.chordColors).length * 2; let c = this.colorGen(ci); this.chordColors[n] = c; } return this.chordColors[n]; }; // fade out the empty circle that is shown when there is no traffic fadeDoughnut = () => { d3.select(DOUGHNUT) .transition() .duration(200) .attr("opacity", 0) .remove(); }; // return the color associated with a router fillArc = (matrixValues, row) => { let router = matrixValues.routerName(row); return this.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 fillChord = (matrixValues, d) => { // aggregate if (matrixValues.aggregate) { return this.fillArc(matrixValues, d.source.index); } // by address let addr = matrixValues.getAddress(d.source.orgindex, d.source.orgsubindex); return this.getChordColor(addr); }; emptyCircle = () => { d3.select(DOUGHNUT).remove(); let arc = d3.svg .arc() .innerRadius(this.innerRadius) .outerRadius(this.textRadius) .startAngle(0) .endAngle(Math.PI * 2); d3.select("#circle") .append("path") .attr("class", "empty") .attr("d", arc); this.setState({ noValues: false }); }; genArcColors = () => { //$scope.arcColors = {}; let routers = this.chordData.getRouters(); routers.forEach(router => { this.getArcColor(router); }); }; genChordColors = () => { if (this.state.legendOptions.byAddress) { Object.keys(this.state.addresses).forEach(address => { // populate this.chordColor for this address this.getChordColor(address); }); } }; chordKey = (d, matrix) => { // sort so that if the soure and target are flipped, the chord doesn't // get destroyed and recreated return this.getRouterNames(d, matrix) .sort() .join("-"); }; getRouterNames = (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 = !typeof d.index === "undefined" ? d.index : d.source.index; let iindex = !typeof d.index === "undefined" ? 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 chordTitle = (d, matrix) => { let rinfo = this.getRouterNames(d, matrix); let from = rinfo[0], to = rinfo[1], address = rinfo[2]; if (!matrix.aggregate) { address += "<br/>"; } let title = address + from + " → " + to + ": " + this.formatNumber(d.source.value); if (d.target.value > 0 && to !== from) { title += "<br/>" + to + " → " + from + ": " + this.formatNumber(d.target.value); } return title; }; arcTitle = (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 + ": " + this.formatNumber(value); }; decorateChordData = (rechord, matrix) => { let data = rechord.chords(); data.forEach((d, i) => { d.key = this.chordKey(d, matrix, false); d.orgIndex = i; d.color = this.fillChord(matrix, d); }); return data; }; decorateArcData = (fn, matrix) => { let fixedGroups = fn(); fixedGroups.forEach(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 = this.getArcColor(fg.router); }); return fixedGroups; }; // create and/or update the chord diagram renderChord = matrix => { this.setState({ addresses: this.chordData.getAddresses() }, () => this.doRenderChord(matrix) ); }; doRenderChord = matrix => { // populate the arcColors object with a color for each router this.genArcColors(); this.genChordColors(); // if all the addresses are excluded, update the message let addressLen = Object.keys(this.state.addresses).length; this.allAddressesFiltered = false; if (addressLen > 0 && this.excludedAddresses.length === addressLen) { this.allAddressesFiltered = true; } this.noValues = false; let matrixMessages, duration = TRANSITION_DURATION; // if there is no data, show an empty circle and a message if (!matrix.hasValues()) { this.noValues = this.arcColors.length === 0; if (!this.theyveBeenWarned) { this.theyveBeenWarned = true; let msg = "There is no message traffic"; if (addressLen !== 0) { msg += " for the selected addresses"; } this.setState({ showEmpty: true, emptyText: msg }); } this.emptyCircle(); matrixMessages = []; } else { matrixMessages = matrix.matrixMessages(); this.setState({ showEmpty: false }); this.theyveBeenWarned = false; this.fadeDoughnut(); } // create a new chord layout so we can animate between the last one and this one let groupBy = matrix.getGroupBy(); let rechord = qdrlayoutChord() .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 = this.decorateArcData(rechord.groups, matrix); // join the decorated data with a d3 selection let arcsGroup = this.svg.selectAll("g.arc").data(rechord.arcData, 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") .attr("aria-label", d => d.key); // each new arc is an svg:path that has a fixed color newArcs .append("svg:path") .style("fill", d => d.color) .style("stroke", d => d.color); newArcs .append("svg:text") .attr("dy", ".35em") .text(d => d.router); // attach event listeners to all arcs (new or old) arcsGroup .on("mouseover", this.mouseoverArc) .on("mousemove", d => { let popupContent = this.arcTitle(d, matrix); this.displayTooltip(d3.event, popupContent); }) .on("mouseout", () => { this.popoverArc = null; this.setState({ showPopup: false }); }); // animate the arcs path to it's new location arcsGroup .select("path") .transition() .duration(duration) //.ease('linear') .attrTween("d", this.arcTween(this.last_chord)); arcsGroup .select("text") .attr("text-anchor", d => (d.angle > Math.PI ? "end" : "begin")) .transition() .duration(duration) .attrTween("transform", this.tickTween(this.last_labels)); // check if the mouse is hovering over an arc. if so, update the tooltip arcsGroup.each(d => { if (this.popoverArc && this.popoverArc.index === d.index) { //let popoverContent = this.arcTitle(d, matrix); //this.displayTooltip(d3.event, popoverContent); } }); // animate the removal of any arcs that went away let exitingArcs = arcsGroup.exit(); exitingArcs .selectAll("text") .transition() .duration(duration / 2) .attrTween("opacity", () => { return t => { return 1 - t; }; }); exitingArcs .selectAll("path") .transition() .duration(duration / 2) .attrTween("d", this.arcTweenExit) .each("end", function() { d3.select(this) .node() .parentNode.remove(); }); // decorate the chord layout's .chord() data with key, color, and orgIndex rechord.chordData = this.decorateChordData(rechord, matrix); let chordPaths = this.svg.selectAll("path.chord").data(rechord.chordData, d => d.key); // new chords are paths chordPaths .enter() .append("path") .attr("class", "chord"); if (!this.switchedByAddress) { // do multiple concurrent tweens on the chords chordPaths .call(this.tweenChordEnds, duration, this.last_chord) .call(this.tweenChordColor, duration, this.last_chord, "stroke") .call(this.tweenChordColor, duration, this.last_chord, "fill"); } else { // switchByAddress is only true when we have new chords chordPaths .attr("d", d => { return this.chordReference(d); }) .attr("stroke", d => d3.rgb(d.color).darker(1)) .attr("fill", d => d.color) .attr("opacity", 1e-6) .transition() .duration(duration / 2) .attr("opacity", 0.67); } // if the mouse is hovering over a chord, update it's tooltip chordPaths.each(d => { if ( this.popoverChord && this.popoverChord.source.orgindex === d.source.orgindex && this.popoverChord.source.orgsubindex === d.source.orgsubindex ) { //let popoverContent = this.chordTitle(d, matrix); //this.displayTooltip(d3.event, popoverContent); } }); // attach mouse event handlers to the chords chordPaths .on("mouseover", this.mouseoverChord) .on("mousemove", d => { this.popoverChord = d; let popoverContent = this.chordTitle(d, matrix); this.displayTooltip(d3.event, popoverContent); }) .on("mouseout", () => { this.popoverChord = null; this.setState({ showPopup: false }); }); let exitingChords = chordPaths.exit().attr("class", "exiting-chord"); exitingChords.remove(); /* if (!this.switchedByAddress) { // shrink chords to their center point upon removal exitingChords .transition() .duration(duration / 2) .attrTween("d", this.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 this.last_chord = rechord; this.last_labels = this.last_chord.arcData; this.switchedByAddress = false; }; displayTooltip = (event, content) => { if (this.popupCancelled) { this.setState({ showPopup: false }); return; } d3.select("#popover-div") .style("left", `${event.pageX + 5}px`) .style("top", `${event.pageY}px`); // show popup this.setState({ showPopup: true, popupContent: content }); }; // animate the disappearance of an arc by shrinking it to its center point 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 t => { return this.arcReference(tween(t)); }; }; // animate the exit of a chord by shrinking it to the center points of its arcs chordTweenExit = d => { let angle = d => (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 t => { return this.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 arcTween = oldLayout => { var oldGroups = {}; if (oldLayout) { oldLayout.arcData.forEach(groupData => { oldGroups[groupData.index] = groupData; }); } return 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 t => { return this.arcReference(tween(t)); }; }; }; // animate all the chords to their new positions tweenChordEnds = (chords, duration, last_layout) => { let oldChords = {}; if (last_layout) { last_layout.chordData.forEach(d => { oldChords[d.key] = d; }); } let self = this; 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", () => { 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 t => { chord.attr("d", self.chordReference(interpolate(t))); }; }); }); }; // animate a chord to its new color tweenChordColor = (chords, duration, last_layout, style) => { let oldChords = {}; if (last_layout) { last_layout.chordData.forEach(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 t => { chord.style(style, interpolate(t)); }; }); }); }; // animate the arc labels to their new locations tickTween = oldArcs => { var oldTicks = {}; if (oldArcs) { oldArcs.forEach(d => { oldTicks[d.key] = d; }); } let angle = d => (d.startAngle + d.endAngle) / 2; return d => { var tween; var old = oldTicks[d.key]; let start = angle(d); let startTranslate = this.textRadius - 40; let orient = d.angle > Math.PI ? "rotate(180)" : ""; if (old) { //there's a matching old group start = angle(old); startTranslate = this.textRadius; } tween = d3.interpolateNumber(start, angle(d)); let same = start === angle(d); let tsame = startTranslate === this.textRadius; let transTween = d3.interpolateNumber(startTranslate, this.textRadius + 10); return t => { let rot = same ? start : tween(t); if (isNaN(rot)) rot = 0; let tra = tsame ? this.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 mouseoverArc = d => { d3.selectAll("path.chord").classed( "fade", p => d.index !== p.source.index && d.index !== p.target.index ); }; // fade all chords except the given one mouseoverChord = d => { this.svg .selectAll("path.chord") .classed( "fade", p => !( p.source.orgindex === d.source.orgindex && p.target.orgindex === d.target.orgindex ) ); }; showAllChords = () => { this.svg.selectAll("path.chord").classed("fade", false); }; // called periodically to refresh the data doUpdate = () => { this.chordData.getMatrix().then(this.renderChord, e => { console.log(ERROR_RENDERING + e); }); }; updateNow = () => { clearInterval(this.interval); this.chordData.getMatrix().then(this.renderChord, function(e) { console.log(ERROR_RENDERING + e); }); this.interval = setInterval(this.doUpdate, TRANSITION_DURATION); }; // one of the legend sections was opened or closed handleOpenChange = (id, isOpen) => { const { legendOptions } = this.state; legendOptions[`${id}Open`] = isOpen; this.setState({ legendOptions }); }; handleChangeOption = (checked, e) => { const { legendOptions } = this.state; const name = e.target.name; legendOptions[name] = checked; this.setState({ legendOptions }, () => { if (name === "isRate") { this.chordData.setRate(this.state.legendOptions.isRate); let doughnut = d3.select(DOUGHNUT); if (!doughnut.empty()) { this.fadeDoughnut(); } } else { d3.select("#legend").classed("byAddress", checked); this.chordData.setConverter( this.state.legendOptions.byAddress ? separateAddresses : aggregateAddresses ); this.switchedByAddress = true; } this.updateNow(); localStorage[CHORDOPTIONSKEY] = JSON.stringify(this.state.legendOptions); }); }; // one of the address checkboxes in the legend was clicked handleChangeAddress = (address, checked) => { const { addresses } = this.state; addresses[address] = checked; this.setState({ addresses }, () => { this.fadeDoughnut(); this.excludedAddresses = []; for (let address in this.state.addresses) { if (!this.state.addresses[address]) this.excludedAddresses.push(address); } localStorage[CHORDFILTERKEY] = JSON.stringify(this.excludedAddresses); if (this.chordData) this.chordData.setFilter(this.excludedAddresses); this.updateNow(); }); }; handleHoverAddress = (address, over) => { if (over) { this.enterLegend(address); } else { this.leaveLegend(); } }; handleHoverRouter = (router, over) => { if (over) { this.enterRouter(router); } else { this.leaveLegend(); } }; // called when mouse enters one of the address legends enterLegend = addr => { if (!this.state.legendOptions.byAddress) return; // fade all chords that don't have this address let indexes = []; this.chordData.last_matrix.rows.forEach((row, r) => { let addresses = this.chordData.last_matrix.getAddresses(r); if (addresses.indexOf(addr) >= 0) indexes.push(r); }); d3.selectAll("path.chord").classed( "fade", p => indexes.indexOf(p.source.orgindex) < 0 && indexes.indexOf(p.target.orgindex) < 0 ); }; // called when mouse enters one of the router legends enterRouter = router => { let indexes = []; // fade all chords that are not associated with this router let agg = this.chordData.last_matrix.aggregate; this.chordData.last_matrix.rows.forEach((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", p => indexes.indexOf(p.source.orgindex) < 0 && indexes.indexOf(p.target.orgindex) < 0 ); }; leaveLegend = () => { this.showAllChords(); }; render() { return ( <TopologyView aria-label="chord-viewer" viewToolbar={ <ChordToolbar handleOpenChange={this.handleOpenChange} handleChangeOption={this.handleChangeOption} handleChangeAddress={this.handleChangeAddress} handleHoverAddress={this.handleHoverAddress} handleHoverRouter={this.handleHoverRouter} isRate={this.state.legendOptions.isRate} byAddress={this.state.legendOptions.byAddress} arcColors={this.arcColors} chordColors={this.chordColors} addresses={this.state.addresses} /> } sideBar={<TopologySideBar show={false}></TopologySideBar>} sideBarOpen={false} className="qdrTopology" > <div id="chordContainer" className="qdrChord"> {this.state.showEmpty ? ( <div aria-label="chord-no-traffic" id="noTraffic"> {this.state.emptyText} </div> ) : ( <div aria-label="chord-traffic" /> )} <div aria-label="chord-diagram" id="chord"></div> <div id="popover-div" className={this.state.showPopup ? "qdrPopup" : "qdrPopup hidden"} ref={el => (this.popupRef = el)} > <QDRPopup content={this.state.popupContent}></QDRPopup> </div> </div> </TopologyView> ); } } export default ChordViewer;