Merge pull request #3262 from weaveworks/3261-use-graph-node-component

Use GraphNode component
This commit is contained in:
Filip Barl
2018-08-09 15:07:00 +02:00
committed by GitHub
12 changed files with 112 additions and 551 deletions
+92 -22
View File
@@ -1,33 +1,103 @@
import React from 'react';
import { Motion } from 'react-motion';
import { connect } from 'react-redux';
import { List as makeList } from 'immutable';
import { GraphNode } from 'weaveworks-ui-components';
import { weakSpring } from 'weaveworks-ui-components/lib/utils/animation';
import {
getMetricValue,
getMetricColor,
} from '../utils/metric-utils';
import { clickNode, enterNode, leaveNode } from '../actions/app-actions';
import { trackAnalyticsEvent } from '../utils/tracking-utils';
import { getNodeColor } from '../utils/color-utils';
import MatchedResults from '../components/matched-results';
import { GRAPH_VIEW_MODE } from '../constants/naming';
import Node from './node';
import NodeNetworksOverlay from './node-networks-overlay';
class NodeContainer extends React.Component {
saveRef = (ref) => {
this.ref = ref;
};
const transformedNode = (otherProps, { x, y, k }) => (
<g transform={`translate(${x},${y}) scale(${k})`}>
<Node {...otherProps} />
</g>
);
handleMouseClick = (nodeId, ev) => {
ev.stopPropagation();
trackAnalyticsEvent('scope.node.click', {
layout: GRAPH_VIEW_MODE,
topologyId: this.props.currentTopology.get('id'),
parentTopologyId: this.props.currentTopology.get('parentId'),
});
this.props.clickNode(nodeId, this.props.label, this.ref.getBoundingClientRect());
};
export default class NodeContainer extends React.PureComponent {
render() {
const {
dx, dy, isAnimated, scale, ...forwardedProps
} = this.props;
if (!isAnimated) {
// Show static node for optimized rendering
return transformedNode(forwardedProps, { x: dx, y: dy, k: scale });
}
renderPrependedInfo = () => {
const { showingNetworks, networks } = this.props;
if (!showingNetworks) return null;
return (
// Animate the node if the layout is sufficiently small
<Motion style={{ x: weakSpring(dx), y: weakSpring(dy), k: weakSpring(scale) }}>
{interpolated => transformedNode(forwardedProps, interpolated)}
</Motion>
<NodeNetworksOverlay networks={networks} />
);
};
renderAppendedInfo = () => {
const matchedMetadata = this.props.matches.get('metadata', makeList());
const matchedParents = this.props.matches.get('parents', makeList());
const matchedDetails = matchedMetadata.concat(matchedParents);
return (
<MatchedResults matches={matchedDetails} />
);
};
render() {
const {
rank, label, pseudo, metric, showingNetworks, networks
} = this.props;
const { hasMetric, height, formattedValue } = getMetricValue(metric);
const metricFormattedValue = !pseudo && hasMetric ? formattedValue : '';
const labelOffset = (showingNetworks && networks) ? 10 : 0;
return (
<GraphNode
id={this.props.id}
shape={this.props.shape}
label={this.props.label}
labelMinor={this.props.labelMinor}
labelOffset={labelOffset}
stacked={this.props.stacked}
highlighted={this.props.highlighted}
color={getNodeColor(rank, label, pseudo)}
size={this.props.size}
isAnimated={this.props.isAnimated}
contrastMode={this.props.contrastMode}
forceSvg={this.props.exportingGraph}
searchTerms={this.props.searchTerms}
metricColor={getMetricColor(metric)}
metricFormattedValue={metricFormattedValue}
metricNumericValue={height}
renderPrependedInfo={this.renderPrependedInfo}
renderAppendedInfo={this.renderAppendedInfo}
onMouseEnter={this.props.enterNode}
onMouseLeave={this.props.leaveNode}
onClick={this.handleMouseClick}
graphNodeRef={this.saveRef}
x={this.props.x}
y={this.props.y}
/>
);
}
}
function mapStateToProps(state) {
return {
searchTerms: [state.get('searchQuery')],
exportingGraph: state.get('exportingGraph'),
showingNetworks: state.get('showingNetworks'),
currentTopology: state.get('currentTopology'),
contrastMode: state.get('contrastMode'),
};
}
export default connect(
mapStateToProps,
{ clickNode, enterNode, leaveNode }
)(NodeContainer);
@@ -4,7 +4,6 @@ import { List as makeList } from 'immutable';
import { connect } from 'react-redux';
import { getNetworkColor } from '../utils/color-utils';
import { NODE_BASE_SIZE } from '../constants/styles';
// Min size is about a quarter of the width, feels about right.
const minBarWidth = 0.25;
@@ -14,7 +13,7 @@ const borderRadius = 0.01;
const offset = 0.67;
const x = scaleBand();
function NodeNetworksOverlay({ stack, networks = makeList(), contrastMode }) {
function NodeNetworksOverlay({ networks = makeList() }) {
const barWidth = Math.max(1, minBarWidth * networks.size);
const yPosition = offset - (barHeight * 0.5);
@@ -38,9 +37,8 @@ function NodeNetworksOverlay({ stack, networks = makeList(), contrastMode }) {
/>
));
const translateY = stack && contrastMode ? 0.15 : 0;
return (
<g transform={`translate(0, ${translateY}) scale(${NODE_BASE_SIZE})`}>
<g transform="translate(0, -5) scale(60)">
{bars.toJS()}
</g>
);
@@ -1,24 +0,0 @@
import React from 'react';
import { NODE_BASE_SIZE } from '../constants/styles';
export default function NodeShapeStack(props) {
const verticalDistance = NODE_BASE_SIZE * (props.contrastMode ? 0.12 : 0.1);
const verticalTranslate = t => `translate(0, ${t * verticalDistance})`;
const Shape = props.shape;
// Stack three shapes on top of one another pretending they are never highlighted.
// Instead, fake the highlight of the whole stack with a vertically stretched shape
// drawn in the background. This seems to give a good approximation of the stack
// highlight and prevents us from needing to do some render-heavy SVG clipping magic.
return (
<g transform={verticalTranslate(-2.5)} className="stack">
<g transform={`${verticalTranslate(1)} scale(1, 1.14)`}>
<Shape className="highlight-only" {...props} />
</g>
<g transform={verticalTranslate(2)}><Shape {...props} highlighted={false} /></g>
<g transform={verticalTranslate(1)}><Shape {...props} highlighted={false} /></g>
<g transform={verticalTranslate(0)}><Shape {...props} highlighted={false} /></g>
</g>
);
}
-91
View File
@@ -1,91 +0,0 @@
import React from 'react';
import classNames from 'classnames';
import { NODE_BASE_SIZE } from '../constants/styles';
import {
getMetricValue,
getMetricColor,
getClipPathDefinition,
} from '../utils/metric-utils';
import {
pathElement,
circleElement,
rectangleElement,
circleShapeProps,
triangleShapeProps,
squareShapeProps,
pentagonShapeProps,
hexagonShapeProps,
heptagonShapeProps,
octagonShapeProps,
cloudShapeProps,
cylinderShapeProps,
dottedCylinderShapeProps,
sheetShapeProps
} from '../utils/node-shape-utils';
import { encodeIdAttribute } from '../utils/dom-utils';
function NodeShape(shapeType, shapeElement, shapeProps, {
id, highlighted, color, metric
}) {
const { height, hasMetric, formattedValue } = getMetricValue(metric);
const className = classNames('shape', `shape-${shapeType}`, { metrics: hasMetric });
const metricStyle = { fill: getMetricColor(metric) };
const clipId = encodeIdAttribute(`metric-clip-${id}`);
return (
<g className={className}>
{highlighted && shapeElement({
className: 'highlight-border',
transform: `scale(${NODE_BASE_SIZE * 0.5})`,
...shapeProps,
})}
{highlighted && shapeElement({
className: 'highlight-shadow',
transform: `scale(${NODE_BASE_SIZE * 0.5})`,
...shapeProps,
})}
{shapeElement({
className: 'background',
transform: `scale(${NODE_BASE_SIZE * 0.48})`,
...shapeProps,
})}
{hasMetric && getClipPathDefinition(clipId, height, 0.48)}
{hasMetric && shapeElement({
className: 'metric-fill',
transform: `scale(${NODE_BASE_SIZE * 0.48})`,
clipPath: `url(#${clipId})`,
style: metricStyle,
...shapeProps,
})}
{shapeElement({
className: 'shadow',
transform: `scale(${NODE_BASE_SIZE * 0.49})`,
...shapeProps,
})}
{shapeElement({
className: 'border',
transform: `scale(${NODE_BASE_SIZE * 0.5})`,
stroke: color,
...shapeProps,
})}
{hasMetric && highlighted ?
<text>{formattedValue}</text> :
<circle className="node" r={NODE_BASE_SIZE * 0.1} />
}
</g>
);
}
export const NodeShapeCircle = props => NodeShape('circle', circleElement, circleShapeProps, props);
export const NodeShapeTriangle = props => NodeShape('triangle', pathElement, triangleShapeProps, props);
export const NodeShapeSquare = props => NodeShape('square', rectangleElement, squareShapeProps, props);
export const NodeShapePentagon = props => NodeShape('pentagon', pathElement, pentagonShapeProps, props);
export const NodeShapeHexagon = props => NodeShape('hexagon', pathElement, hexagonShapeProps, props);
export const NodeShapeHeptagon = props => NodeShape('heptagon', pathElement, heptagonShapeProps, props);
export const NodeShapeOctagon = props => NodeShape('octagon', pathElement, octagonShapeProps, props);
export const NodeShapeCloud = props => NodeShape('cloud', pathElement, cloudShapeProps, props);
export const NodeShapeCylinder = props => NodeShape('cylinder', pathElement, cylinderShapeProps, props);
export const NodeShapeDottedCylinder = props => NodeShape('dottedcylinder', pathElement, dottedCylinderShapeProps, props);
export const NodeShapeSheet = props => NodeShape('sheet', pathElement, sheetShapeProps, props);
-191
View File
@@ -1,191 +0,0 @@
import React from 'react';
import { connect } from 'react-redux';
import classnames from 'classnames';
import { Map as makeMap, List as makeList } from 'immutable';
import { clickNode, enterNode, leaveNode } from '../actions/app-actions';
import { getNodeColor } from '../utils/color-utils';
import MatchedText from '../components/matched-text';
import MatchedResults from '../components/matched-results';
import { trackAnalyticsEvent } from '../utils/tracking-utils';
import { GRAPH_VIEW_MODE } from '../constants/naming';
import { NODE_BASE_SIZE } from '../constants/styles';
import NodeShapeStack from './node-shape-stack';
import NodeNetworksOverlay from './node-networks-overlay';
import {
NodeShapeCircle,
NodeShapeTriangle,
NodeShapeSquare,
NodeShapePentagon,
NodeShapeHexagon,
NodeShapeHeptagon,
NodeShapeOctagon,
NodeShapeCloud,
NodeShapeCylinder,
NodeShapeDottedCylinder,
NodeShapeSheet
} from './node-shapes';
const labelWidth = 1.2 * NODE_BASE_SIZE;
const nodeShapes = {
circle: NodeShapeCircle,
triangle: NodeShapeTriangle,
square: NodeShapeSquare,
pentagon: NodeShapePentagon,
hexagon: NodeShapeHexagon,
heptagon: NodeShapeHeptagon,
octagon: NodeShapeOctagon,
cloud: NodeShapeCloud,
cylinder: NodeShapeCylinder,
dottedcylinder: NodeShapeDottedCylinder,
storagesheet: NodeShapeSheet
};
function stackedShape(Shape) {
const factory = React.createFactory(NodeShapeStack);
return props => factory(Object.assign({}, props, {shape: Shape}));
}
function getNodeShape({ shape, stack }) {
const nodeShape = nodeShapes[shape];
if (!nodeShape) {
throw new Error(`Unknown shape: ${shape}!`);
}
return stack ? stackedShape(nodeShape) : nodeShape;
}
class Node extends React.Component {
constructor(props, context) {
super(props, context);
this.state = {
hovered: false,
};
this.handleMouseClick = this.handleMouseClick.bind(this);
this.handleMouseEnter = this.handleMouseEnter.bind(this);
this.handleMouseLeave = this.handleMouseLeave.bind(this);
this.saveShapeRef = this.saveShapeRef.bind(this);
}
renderSvgLabels(labelClassName, labelMinorClassName, labelOffsetY) {
const { label, labelMinor } = this.props;
return (
<g className="node-labels-container">
<text className={labelClassName} y={13 + labelOffsetY} textAnchor="middle">{label}</text>
<text className={labelMinorClassName} y={30 + labelOffsetY} textAnchor="middle">
{labelMinor}
</text>
</g>
);
}
renderStandardLabels(labelClassName, labelMinorClassName, labelOffsetY, mouseEvents) {
const { label, labelMinor, matches = makeMap() } = this.props;
const matchedMetadata = matches.get('metadata', makeList());
const matchedParents = matches.get('parents', makeList());
const matchedNodeDetails = matchedMetadata.concat(matchedParents);
return (
<foreignObject
className="node-labels-container"
y={labelOffsetY}
x={-0.5 * labelWidth}
width={labelWidth}
height="5em">
<div className="node-label-wrapper" {...mouseEvents}>
<div className={labelClassName}>
<MatchedText text={label} match={matches.get('label')} />
</div>
<div className={labelMinorClassName}>
<MatchedText text={labelMinor} match={matches.get('labelMinor')} />
</div>
<MatchedResults matches={matchedNodeDetails} />
</div>
</foreignObject>
);
}
render() {
const {
focused, highlighted, networks, pseudo, rank, label, transform,
exportingGraph, showingNetworks, stack, id, metric
} = this.props;
const { hovered } = this.state;
const color = getNodeColor(rank, label, pseudo);
const truncate = !focused && !hovered;
const labelOffsetY = (showingNetworks && networks) ? 40 : 28;
const nodeClassName = classnames('node', { highlighted, hovered, pseudo });
const labelClassName = classnames('node-label', { truncate });
const labelMinorClassName = classnames('node-label-minor', { truncate });
const NodeShapeType = getNodeShape(this.props);
const mouseEvents = {
onClick: this.handleMouseClick,
onMouseEnter: this.handleMouseEnter,
onMouseLeave: this.handleMouseLeave,
};
return (
<g className={nodeClassName} transform={transform}>
{exportingGraph ?
this.renderSvgLabels(labelClassName, labelMinorClassName, labelOffsetY) :
this.renderStandardLabels(labelClassName, labelMinorClassName, labelOffsetY, mouseEvents)}
<g {...mouseEvents} ref={this.saveShapeRef}>
<NodeShapeType
id={id}
highlighted={highlighted}
color={color}
metric={metric}
contrastMode={this.props.contrastMode}
/>
</g>
{showingNetworks && <NodeNetworksOverlay networks={networks} stack={stack} />}
</g>
);
}
saveShapeRef(ref) {
this.shapeRef = ref;
}
handleMouseClick(ev) {
ev.stopPropagation();
trackAnalyticsEvent('scope.node.click', {
layout: GRAPH_VIEW_MODE,
topologyId: this.props.currentTopology.get('id'),
parentTopologyId: this.props.currentTopology.get('parentId'),
});
this.props.clickNode(this.props.id, this.props.label, this.shapeRef.getBoundingClientRect());
}
handleMouseEnter() {
this.props.enterNode(this.props.id);
this.setState({ hovered: true });
}
handleMouseLeave() {
this.props.leaveNode(this.props.id);
this.setState({ hovered: false });
}
}
function mapStateToProps(state) {
return {
exportingGraph: state.get('exportingGraph'),
showingNetworks: state.get('showingNetworks'),
currentTopology: state.get('currentTopology'),
contrastMode: state.get('contrastMode'),
};
}
export default connect(
mapStateToProps,
{ clickNode, enterNode, leaveNode }
)(Node);
@@ -21,6 +21,7 @@ import {
layoutEdgesSelector
} from '../selectors/graph-view/layout';
import { NODE_BASE_SIZE } from '../constants/styles';
import {
BLURRED_EDGES_LAYER,
BLURRED_NODES_LAYER,
@@ -148,7 +149,7 @@ class NodesChartElements extends React.Component {
}
renderNode(node) {
const { isAnimated, contrastMode } = this.props;
const { isAnimated } = this.props;
return (
<NodeContainer
matches={node.get('matches')}
@@ -157,18 +158,17 @@ class NodesChartElements extends React.Component {
focused={node.get('focused')}
highlighted={node.get('highlighted')}
shape={node.get('shape')}
stack={node.get('stack')}
stacked={node.get('stack')}
key={node.get('id')}
id={node.get('id')}
label={node.get('label')}
labelMinor={node.get('labelMinor')}
pseudo={node.get('pseudo')}
rank={node.get('rank')}
dx={node.get('x')}
dy={node.get('y')}
scale={node.get('scale')}
x={node.get('x')}
y={node.get('y')}
size={node.get('scale') * NODE_BASE_SIZE}
isAnimated={isAnimated}
contrastMode={contrastMode}
/>
);
}
@@ -1,6 +1,5 @@
import React from 'react';
import MatchedText from './matched-text';
import { MatchedText } from 'weaveworks-ui-components';
const SHOW_ROW_COUNT = 2;
const MAX_MATCH_LENGTH = 24;
@@ -13,10 +12,12 @@ const Match = match => (
{match.label}:
</span>
<MatchedText
noBorder
text={match.text}
match={match}
maxLength={MAX_MATCH_LENGTH}
truncate={match.truncate} />
truncate={match.truncate}
/>
</div>
</div>
);
+2 -2
View File
@@ -23,7 +23,7 @@ const loadScale = scaleLog().domain([0.01, 100]).range([0, 1]);
export function getMetricValue(metric) {
if (!metric) {
return {height: 0, value: null, formattedValue: 'n/a'};
return { height: 0, value: null, formattedValue: 'n/a' };
}
const m = metric.toJS();
const { value } = m;
@@ -35,7 +35,7 @@ export function getMetricValue(metric) {
max = null;
}
let displayedValue = Number(value).toFixed(1);
let displayedValue = Number(value);
if (displayedValue > 0 && (!max || displayedValue < max)) {
const baseline = 0.1;
displayedValue = (valuePercentage * (1 - (baseline * 2))) + baseline;
@@ -1,34 +0,0 @@
import React from 'react';
import range from 'lodash/range';
import { line, curveCardinalClosed } from 'd3-shape';
import { UNIT_CLOUD_PATH, UNIT_CYLINDER_PATH, SHEET } from '../constants/styles';
export const pathElement = React.createFactory('path');
export const circleElement = React.createFactory('circle');
export const rectangleElement = React.createFactory('rect');
function curvedUnitPolygonPath(n) {
const curve = curveCardinalClosed.tension(0.65);
const spline = line().curve(curve);
const innerAngle = (2 * Math.PI) / n;
return spline(range(0, n).map(k => [
Math.sin(k * innerAngle),
-Math.cos(k * innerAngle),
]));
}
export const circleShapeProps = { r: 1 };
export const triangleShapeProps = { d: curvedUnitPolygonPath(3) };
export const squareShapeProps = {
width: 1.8, height: 1.8, rx: 0.4, ry: 0.4, x: -0.9, y: -0.9
};
export const pentagonShapeProps = { d: curvedUnitPolygonPath(5) };
export const hexagonShapeProps = { d: curvedUnitPolygonPath(6) };
export const heptagonShapeProps = { d: curvedUnitPolygonPath(7) };
export const octagonShapeProps = { d: curvedUnitPolygonPath(8) };
export const cloudShapeProps = { d: UNIT_CLOUD_PATH };
export const cylinderShapeProps = { d: UNIT_CYLINDER_PATH };
export const dottedCylinderShapeProps = { d: UNIT_CYLINDER_PATH };
export const sheetShapeProps = { d: SHEET };
+2 -170
View File
@@ -435,97 +435,8 @@ a {
fill: $text-secondary-color;
}
.nodes-chart-elements .node {
text-align: center;
.node-label {
color: $text-color;
}
.node-label-minor {
line-height: 125%;
}
.node-labels-container {
transform: scale($node-text-scale);
pointer-events: none;
}
.node-label-wrapper {
//
// Base line height doesn't hop across foreignObject =/
//
line-height: 150%;
//
// inline-block so wrapper is only as wide as content.
//
display: inline-block;
//
// - inline-block gets a different baseline depending on overflow value
// - this element gets its overflow value changed sometimes.
// - explicitly set the baseline so the text doesn't jump up and down.
// http://stackoverflow.com/questions/9273016
//
vertical-align: top;
cursor: pointer;
pointer-events: all;
font-size: $font-size-small;
width: 100%;
}
.node-label-minor {
color: $text-secondary-color;
font-size: $font-size-tiny;
}
.node-label, .node-label-minor {
span {
border-radius: $border-radius-soft;
}
span:not(.match) {
padding: 0 0.25em;
background-color: $label-background-color;
}
span:empty {
padding: 0;
}
}
.matched-results {
background-color: $label-background-color;
}
&.pseudo {
cursor: default;
.node-label {
fill: $text-secondary-color;
}
.node-label-minor {
fill: $text-tertiary-color;
}
.node {
opacity: $node-pseudo-opacity;
}
.border {
opacity: $node-pseudo-opacity;
stroke: $text-tertiary-color;
}
}
.node-label, .node-label-minor {
text-align: center;
}
.match {
background-color: transparentize($color-blue-400, 0.25);
border: 1px solid $color-blue-400;
}
.nodes-chart-elements .matched-results {
background-color: $label-background-color;
}
.edge {
@@ -555,85 +466,6 @@ a {
}
}
.stack .highlight-only {
.background { display: none; }
.shadow { display: none; }
.border { display: none; }
.node { display: none; }
}
.stack .shape .metric-fill {
display: none;
}
.shape {
transform: scale(1);
cursor: pointer;
.highlight-border {
fill: none;
stroke: $color-blue-400;
stroke-width: 0.7 + $node-highlight-stroke-width * 2;
stroke-opacity: $node-highlight-stroke-opacity;
}
.highlight-shadow {
fill: none;
stroke: $color-white;
stroke-width: 0.7;
stroke-opacity: $node-highlight-shadow-opacity;
}
.background {
stroke: none;
fill: $background-lighter-color;
}
.metric-fill {
stroke: none;
fill-opacity: 0.7;
}
.border {
fill: none;
stroke-opacity: 1;
stroke-width: $node-border-stroke-width;
}
&.metrics .border {
stroke-opacity: 0.3;
}
.shadow {
fill: none;
stroke: $background-color;
stroke-width: $node-shadow-stroke-width;
}
.node {
fill: $text-color;
stroke: $background-lighter-color;
stroke-width: 0.05;
}
text {
transform: scale($node-text-scale);
font-size: $font-size-tiny;
dominant-baseline: middle;
text-anchor: middle;
}
}
.shape-dottedcylinder {
.border{
stroke-dasharray: 0.07;
}
}
.stack .shape .border {
stroke-width: $node-border-stroke-width * 0.8;
}
.edge-marker {
color: $edge-color;
fill: $edge-color;
+1 -1
View File
@@ -44,7 +44,7 @@
"reselect": "3.0.1",
"reselect-map": "1.0.3",
"styled-components": "2.2.4",
"weaveworks-ui-components": "0.11.8",
"weaveworks-ui-components": "0.11.14",
"whatwg-fetch": "2.0.3",
"xterm": "3.3.0"
},
+3 -3
View File
@@ -8397,9 +8397,9 @@ wd@^0.4.0:
underscore.string "~3.0.3"
vargs "~0.1.0"
weaveworks-ui-components@0.11.8:
version "0.11.8"
resolved "https://registry.yarnpkg.com/weaveworks-ui-components/-/weaveworks-ui-components-0.11.8.tgz#4e649e418f5fb1746b908cc4edaa77ebadaf75e8"
weaveworks-ui-components@0.11.14:
version "0.11.14"
resolved "https://registry.yarnpkg.com/weaveworks-ui-components/-/weaveworks-ui-components-0.11.14.tgz#5f72f8a5059bc9f28295b91164c17e0780906f9e"
dependencies:
classnames "2.2.5"
d3-drag "1.2.1"