From 058486944e0fae5603808ce902b956d69301f33d Mon Sep 17 00:00:00 2001 From: Riley Bauer <34456002+rileyjbauer@users.noreply.github.com> Date: Thu, 21 Feb 2019 11:34:41 -0800 Subject: [PATCH] Update graph styling (#829) * Updates styling for the graphs * Break all diagonal edges into vertical and horizontal components * Checkpointing. A lot of minor adjustments being made * Change graph rendering to only use horizontal and vertical lines for edges. Previously diagonal edges are converted into two vertical edges and one horiztonal * Small cleanup * Hovering over node now highlights all incoming and outgoing edges, as does selecting a node More fixes, stop stacking starting edges * Clean up * Remove edge starting circle code * Returns the DAG to using arbitrary angles rather than right angles * Adds small vertical segment to end of all edges * Significant clean up and updating tests * A little more clean up and adding tests for the new Status util function * One more test for Status * Increase node font weight * PR comments * A little more cleanup in Graph --- frontend/package-lock.json | 72 ++ frontend/src/Css.tsx | 45 +- frontend/src/components/CompareTable.tsx | 2 +- frontend/src/components/CustomTable.tsx | 6 +- frontend/src/components/Graph.tsx | 308 ++++-- frontend/src/components/SidePanel.tsx | 4 +- frontend/src/components/Toolbar.tsx | 4 +- .../src/components/UploadPipelineDialog.tsx | 6 +- .../__snapshots__/Graph.test.tsx.snap | 993 ++++++++++++------ .../__snapshots__/PlotCard.test.tsx.snap | 8 +- frontend/src/lib/Constants.ts | 21 + frontend/src/lib/StaticGraphParser.ts | 20 +- frontend/src/lib/WorkflowParser.test.ts | 5 +- frontend/src/lib/WorkflowParser.ts | 10 +- frontend/src/pages/ExperimentDetails.tsx | 2 +- frontend/src/pages/PipelineDetails.tsx | 6 +- frontend/src/pages/Status.test.tsx | 52 +- frontend/src/pages/Status.tsx | 37 +- .../ExperimentList.test.tsx.snap | 6 +- .../__snapshots__/RunDetails.test.tsx.snap | 4 +- .../pages/__snapshots__/Status.test.tsx.snap | 16 +- 21 files changed, 1125 insertions(+), 502 deletions(-) create mode 100644 frontend/src/lib/Constants.ts diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 6ec2fe4cdcb..7a27f4045a4 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -123,6 +123,14 @@ "react-is": "^16.6.3" } }, + "@pleasetrythisathome/react.animate": { + "version": "0.0.4", + "resolved": "https://registry.npmjs.org/@pleasetrythisathome/react.animate/-/react.animate-0.0.4.tgz", + "integrity": "sha1-R5d7ecXP70GZJhrCTpvQvpv26Ic=", + "requires": { + "ease-component": "^1.0.0" + } + }, "@types/body-parser": { "version": "1.17.0", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.17.0.tgz", @@ -3314,6 +3322,16 @@ "sha.js": "^2.4.8" } }, + "create-react-class": { + "version": "15.6.3", + "resolved": "https://registry.npmjs.org/create-react-class/-/create-react-class-15.6.3.tgz", + "integrity": "sha512-M+/3Q6E6DLO6Yx3OwrWjwHBnvfXXYA7W+dFjt/ZDBemHO1DDZhsalX/NUtnTYclN6GfnBDRh4qRHjcDHmlJBJg==", + "requires": { + "fbjs": "^0.8.9", + "loose-envify": "^1.3.1", + "object-assign": "^4.1.1" + } + }, "cross-spawn": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-5.1.0.tgz", @@ -4292,6 +4310,11 @@ "xtend": "^4.0.0" } }, + "ease-component": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/ease-component/-/ease-component-1.0.0.tgz", + "integrity": "sha1-s3VybbC1sEWVt3RAOW/sfapdd8k=" + }, "ecc-jsbn": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/ecc-jsbn/-/ecc-jsbn-0.1.2.tgz", @@ -5043,6 +5066,27 @@ "ua-parser-js": "^0.7.18" } }, + "fbp": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/fbp/-/fbp-1.7.0.tgz", + "integrity": "sha1-cZN6z85Ny6KafFNKacL9gtB2BxQ=" + }, + "fbp-graph": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/fbp-graph/-/fbp-graph-0.4.0.tgz", + "integrity": "sha512-u2KWjZdrnUjvbyLvJ8Lyinz+7bxHD2FIwJnY9axQcFwvdiRg5jpmS2eJTvU5hYrMMbDiSHoXdBeA3hyPl9ZDaA==", + "requires": { + "clone": "^2.1.0", + "fbp": "^1.6.0" + }, + "dependencies": { + "clone": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz", + "integrity": "sha1-G39Ln1kfHo+DZwQBYANFoCiHQ18=" + } + } + }, "fd-slicer": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.0.1.tgz", @@ -5192,6 +5236,11 @@ "debug": "=3.1.0" } }, + "font-awesome": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/font-awesome/-/font-awesome-4.7.0.tgz", + "integrity": "sha1-j6jPBBGhoxr9B7BtKQK7n8gVoTM=" + }, "for-each": { "version": "0.3.3", "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.3.tgz", @@ -6004,6 +6053,11 @@ "duplexer": "^0.1.1" } }, + "hammerjs": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/hammerjs/-/hammerjs-2.0.8.tgz", + "integrity": "sha1-BO93hiz/K7edMPdpIJWTAiK/YPE=" + }, "handle-thing": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/handle-thing/-/handle-thing-1.2.5.tgz", @@ -8173,6 +8227,19 @@ "graceful-fs": "^4.1.9" } }, + "klayjs": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/klayjs/-/klayjs-0.2.1.tgz", + "integrity": "sha1-rLDvCmB8C86rAuhQGkK3WzFQZyA=" + }, + "klayjs-noflo": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/klayjs-noflo/-/klayjs-noflo-0.3.1.tgz", + "integrity": "sha1-CS/lXMKJgFWTUDveUAG2pe3bSV8=", + "requires": { + "klayjs": "^0.2.1" + } + }, "latest-version": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/latest-version/-/latest-version-3.1.0.tgz", @@ -13406,6 +13473,11 @@ "safe-buffer": "^5.0.1" } }, + "tv4": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/tv4/-/tv4-1.3.0.tgz", + "integrity": "sha1-0CDIRvrdUMhVq7JeuuzGj8EPeWM=" + }, "tweetnacl": { "version": "0.14.5", "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz", diff --git a/frontend/src/Css.tsx b/frontend/src/Css.tsx index 34e55e89e45..f6c7bae5923 100644 --- a/frontend/src/Css.tsx +++ b/frontend/src/Css.tsx @@ -22,23 +22,26 @@ export const color = { activeBg: '#eaf1fd', alert: '#f9ab00', // Google yellow 600 background: '#fff', + blue: '#4285f4', // Google blue 500F disabledBg: '#ddd', divider: '#e0e0e0', - errorBg: '#FBE9E7', - errorText: '#D50000', + errorBg: '#fbe9e7', + errorText: '#d50000', foreground: '#000', - graphBg: '#f5f5f5', - hoverBg: '#eee', - inactive: '#5F6368', + graphBg: '#f2f2f2', + grey: '#5f6368', // Google grey 500 + inactive: '#5f6368', + lightGrey: '#eee', // Google grey 200 lowContrast: '#80868b', // Google grey 600 secondaryText: 'rgba(0, 0, 0, .88)', separator: '#e8e8e8', - strong: '#212121', + strong: '#202124', // Google grey 900 success: '#34a853', + successWeak: '#e6f4ea', // Google green 50 theme: '#1a73e8', themeDarker: '#0b59dc', warningBg: '#f9f9e1', - weak: '#9AA0A6', + weak: '#9aa0a6', }; export const dimension = { @@ -52,6 +55,24 @@ export const dimension = { xsmall: 32, }; +// tslint:disable:object-literal-sort-keys +export const zIndex = { + DROP_ZONE_OVERLAY: 1, + GRAPH_NODE: 1, + BUSY_OVERLAY: 2, + PIPELINE_SUMMARY_CARD: 2, + SIDE_PANEL: 2, +}; + +export const fontsize = { + small: 12, + base: 14, + medium: 16, + large: 18, + title: 18, +}; +// tslint:enable:object-literal-sort-keys + const baseSpacing = 24; export const spacing = { base: baseSpacing, @@ -64,14 +85,6 @@ export const fonts = { secondary: '"Roboto", "Helvetica Neue", sans-serif', }; -export const fontsize = { - base: 14, - large: 18, - medium: 16, - small: 12, - title: 18, -}; - const palette = { primary: { dark: color.themeDarker, @@ -184,7 +197,7 @@ export const commonCss = stylesheet({ position: 'absolute', right: 0, top: 0, - zIndex: 1, + zIndex: zIndex.BUSY_OVERLAY, }, buttonAction: { $nest: { diff --git a/frontend/src/components/CompareTable.tsx b/frontend/src/components/CompareTable.tsx index 1806da19142..26edb18b8f1 100644 --- a/frontend/src/components/CompareTable.tsx +++ b/frontend/src/components/CompareTable.tsx @@ -28,7 +28,7 @@ const css = stylesheet({ padding: 5, }, labelCell: { - backgroundColor: '#eee', + backgroundColor: color.lightGrey, fontWeight: 'bold', maxWidth: 200, minWidth: 50, diff --git a/frontend/src/components/CustomTable.tsx b/frontend/src/components/CustomTable.tsx index 3847a708757..cd7633f7b0d 100644 --- a/frontend/src/components/CustomTable.tsx +++ b/frontend/src/components/CustomTable.tsx @@ -32,7 +32,7 @@ import Tooltip from '@material-ui/core/Tooltip'; import WarningIcon from '@material-ui/icons/WarningRounded'; import { ListRequest } from '../lib/Apis'; import { classes, stylesheet } from 'typestyle'; -import { fonts, fontsize, dimension, commonCss, color, padding } from '../Css'; +import { fonts, fontsize, dimension, commonCss, color, padding, zIndex } from '../Css'; import { logger } from '../lib/Utils'; import { ApiFilter, PredicateOp } from '../apis/filter/api'; import { debounce } from 'lodash'; @@ -169,6 +169,7 @@ export const css = stylesheet({ }, selectionToggle: { marginRight: 12, + minWidth: 32, }, verticalAlignInitial: { verticalAlign: 'initial', @@ -348,7 +349,8 @@ export default class CustomTable extends React.Component
- + )} {/* Empty experience */} diff --git a/frontend/src/components/Graph.tsx b/frontend/src/components/Graph.tsx index d1244da3094..c753c75b85f 100644 --- a/frontend/src/components/Graph.tsx +++ b/frontend/src/components/Graph.tsx @@ -17,60 +17,50 @@ import * as dagre from 'dagre'; import * as React from 'react'; import { classes, stylesheet } from 'typestyle'; -import { fontsize, color } from '../Css'; +import { fontsize, color, fonts, zIndex } from '../Css'; +import { Constants } from '../lib/Constants'; -interface Line { +interface Segment { + angle: number; + length: number; x1: number; - y1: number; x2: number; + y1: number; y2: number; - distance: number; - xMid: number; - yMid: number; - angle: number; - left: number; } interface Edge { color?: string; + isPlaceholder?: boolean; from: string; + segments: Segment[]; to: string; - lines: Line[]; - isPlaceholder?: boolean; } const css = stylesheet({ + arrowHead: { + borderColor: color.grey + ' transparent transparent transparent', + borderStyle: 'solid', + borderWidth: '7px 6px 0 6px', + content: `''`, + position: 'absolute', + }, icon: { - margin: 5, + padding: '5px 7px 0px 7px', }, label: { + color: color.strong, flexGrow: 1, - fontSize: 15, - lineHeight: '2em', - margin: 'auto', + fontFamily: fonts.secondary, + fontSize: 13, + fontWeight: 500, + lineHeight: '16px', + margin: 10, overflow: 'hidden', - paddingLeft: 15, textOverflow: 'ellipsis', whiteSpace: 'nowrap', }, - lastEdgeLine: { - $nest: { - // Arrowhead - '&::after': { - borderColor: `${color.theme} transparent transparent transparent`, - borderStyle: 'solid', - borderWidth: '7px 6px 0 6px', - clear: 'both', - content: `''`, - left: -5, - position: 'absolute', - top: -5, - transform: 'rotate(90deg)', - }, - }, - }, line: { - borderTop: `2px solid ${color.theme}`, position: 'absolute', }, node: { @@ -80,9 +70,8 @@ const css = stylesheet({ }, }, backgroundColor: color.background, - border: `solid 1px ${color.theme}`, - borderRadius: 5, - boxShadow: '1px 1px 5px #aaa', + border: 'solid 1px #d6d6d6', + borderRadius: 3, boxSizing: 'content-box', color: '#124aa4', cursor: 'pointer', @@ -90,16 +79,16 @@ const css = stylesheet({ fontSize: fontsize.medium, margin: 10, position: 'absolute', + zIndex: zIndex.GRAPH_NODE, }, nodeSelected: { - backgroundColor: '#e4ebff !important', - borderColor: color.theme, + border: `solid 2px ${color.theme}`, }, placeholderNode: { margin: 10, position: 'absolute', // TODO: can this be calculated? - transform: 'translate(73px, 16px)' + transform: 'translate(71px, 14px)' }, root: { backgroundColor: color.graphBg, @@ -108,16 +97,6 @@ const css = stylesheet({ overflow: 'auto', position: 'relative', }, - startCircle: { - backgroundColor: color.background, - border: `1px solid ${color.theme}`, - borderRadius: 7, - content: '', - display: 'inline-block', - height: 8, - position: 'absolute', - width: 8, - }, }); interface GraphProps { @@ -126,38 +105,101 @@ interface GraphProps { selectedNodeId?: string; } -export default class Graph extends React.Component { +interface GraphState { + hoveredNode?: string; +} + +export default class Graph extends React.Component { + private LEFT_OFFSET = 100; + private TOP_OFFSET = 44; + private EDGE_THICKNESS = 2; + private EDGE_X_BUFFER = Math.round(Constants.NODE_WIDTH / 6); + + constructor(props: any) { + super(props); + + this.state = {}; + } + public render(): JSX.Element | null { const { graph } = this.props; - const displayEdges: Edge[] = []; - const displayEdgeStartPoints: number[][] = []; if (!graph.nodes().length) { return null; } + dagre.layout(graph); + const displayEdges: Edge[] = []; // Creates the lines that constitute the edges connecting the graph. graph.edges().forEach((edgeInfo) => { const edge = graph.edge(edgeInfo); - const lines: Line[] = []; + const segments: Segment[] = []; + if (edge.points.length > 1) { for (let i = 1; i < edge.points.length; i++) { - const x1 = edge.points[i - 1].x; - const y1 = edge.points[i - 1].y; - const x2 = edge.points[i].x; - const y2 = edge.points[i].y; - // The + 0.5 at the end of 'distance' helps fill out the elbows of the edges. - const distance = Math.sqrt(Math.pow(x1 - x2, 2) + Math.pow(y1 - y2, 2)) + 0.5; - const xMid = (x1 + x2) / 2; - const yMid = (y1 + y2) / 2; - const angle = Math.atan2(y1 - y2, x1 - x2) * 180 / Math.PI; - const left = xMid - (distance / 2); - lines.push({ x1, y1, x2, y2, distance, xMid, yMid, angle, left }); - - // Store the first point of the edge to draw the edge start circle + + let xStart = edge.points[i - 1].x; + let yStart = edge.points[i - 1].y; + + // Adjustments made to the start of the first segment for each edge to ensure that it + // begins at the bottom of the source node and that there are at least EDGE_X_BUFFER + // pixels between it and the right and left side of the node. + // Note that these adjustments may cause edges to overlap with nodes since we are + // deviating from the explicit layout provided by dagre. if (i === 1) { - displayEdgeStartPoints.push([x1, y1]); + const sourceNode = graph.node(edgeInfo.v); + + // Set the edge's first segment to start at the bottom of the source node. + yStart = sourceNode.y + (sourceNode.height / 2) - 3; + + xStart = this._ensureXIsWithinNode(sourceNode, xStart); + } + + let xEnd = edge.points[i].x; + let yEnd = edge.points[i].y; + + const finalSegment = i === edge.points.length - 1; + + // Adjustments made to the end of the final segment for each edge to ensure that it ends + // at the top of the destination node and that there are at least EDGE_X_BUFFER pixels + // between it and the right and left side of the node. The adjustments are only needed + // when there are multiple inbound edges as dagre seems to always layout a single inbound + // edge so that it terminates at the center-top of the destination node. For this reason, + // placeholder nodes do not need adjustments since they always have only a single inbound + // edge. + // Note that these adjustments may cause edges to overlap with nodes since we are + // deviating from the explicit layout provided by dagre. + if (finalSegment) { + const destinationNode = graph.node(edgeInfo.w); + + // Placeholder nodes never need adjustment because they always have only a single + // incoming edge. + if (!destinationNode.isPlaceholder) { + // Set the edge's final segment to terminate at the top of the destination node. + yEnd = destinationNode.y - this.TOP_OFFSET + 5; + + xEnd = this._ensureXIsWithinNode(destinationNode, xEnd); + } + } + + // For the final segment of the edge, if the segment is diagonal, split it into a diagonal + // and a vertical piece so that all edges terminate with a vertical segment. + if (finalSegment && xStart !== xEnd) { + const yHalf = (yStart + yEnd) / 2; + this._addDiagonalSegment(segments, xStart, yStart, xEnd, yHalf); + + // Vertical segment + segments.push({ + angle: 270, + length: yEnd - yHalf, + x1: xEnd - 5, + x2: xEnd, + y1: yHalf + 4, + y2: yEnd, + }); + } else { + this._addDiagonalSegment(segments, xStart, yStart, xEnd, yEnd); } } } @@ -165,16 +207,29 @@ export default class Graph extends React.Component { color: edge.color, from: edgeInfo.v, isPlaceholder: edge.isPlaceholder, - lines, + segments, to: edgeInfo.w }); }); + const { hoveredNode } = this.state; + const highlightNode = this.props.selectedNodeId || hoveredNode; + return (
{graph.nodes().map(id => Object.assign(graph.node(id), { id })).map((node, i) => (
{ + if (!this.props.selectedNodeId) { + this.setState({ hoveredNode: node.id }); + } + }} + onMouseLeave={() => { + if (this.state.hoveredNode === node.id) { + this.setState({ hoveredNode: undefined }); + } + }} onClick={() => (!node.isPlaceholder && this.props.onClick) && this.props.onClick(node.id)} style={{ backgroundColor: node.bgColor, left: node.x, @@ -184,39 +239,102 @@ export default class Graph extends React.Component { transition: 'left 0.5s, top 0.5s', width: node.width, }}> -
{node.label}
-
{node.icon}
+ {!node.isPlaceholder && (
{node.label}
)} +
{node.icon}
))} - {displayEdges.map((edge, i) => ( -
- {edge.lines.map((line, l) => ( -
{ + const edgeColor = this._getEdgeColor(edge, highlightNode); + const lastSegment = edge.segments[edge.segments.length - 1]; + return ( +
+ {edge.segments.map((segment, l) => ( +
+ ))} + {/* Arrowhead */} + {!edge.isPlaceholder && lastSegment.x2 !== undefined && lastSegment.y2 !== undefined && ( +
- ))} -
- ))} - - {displayEdgeStartPoints.map((point, i) => ( -
- ))} + )} +
+ ); + })}
); } + + private _addDiagonalSegment( + segments: Segment[], + xStart: number, + yStart: number, + xEnd: number, + yEnd: number): void { + const xMid = (xStart + xEnd) / 2; + // The + 0.5 at the end of 'length' helps fill out the elbows of the edges. + const length = Math.sqrt(Math.pow(xStart - xEnd, 2) + Math.pow(yStart - yEnd, 2)) + 0.5; + const x1 = xMid - (length / 2); + const y1 = (yStart + yEnd) / 2; + const angle = Math.atan2(yStart - yEnd, xStart - xEnd) * 180 / Math.PI; + segments.push({ + angle, + length, + x1, + x2: xEnd, + y1, + y2: yEnd, + }); + } + + /** + * Adjusts the x positioning of the start or end of an edge so that it is at least EDGE_X_BUFFER + * pixels in from the left and right. + * @param node the node where the edge is originating from or terminating at + * @param originalX the initial x position provided by dagre + */ + private _ensureXIsWithinNode(node: dagre.Node, originalX: number): number { + // If the original X value was too far to the right, move it EDGE_X_BUFFER pixels + // in from the left end of the node. + const rightmostAcceptableLoc = node.x + node.width - this.LEFT_OFFSET - this.EDGE_X_BUFFER; + if (rightmostAcceptableLoc <= originalX) { + return rightmostAcceptableLoc; + } + + // If the original X value was too far to the left, move it EDGE_X_BUFFER pixels + // in from the left end of the node. + const leftmostAcceptableLoc = node.x - this.LEFT_OFFSET + this.EDGE_X_BUFFER; + if (leftmostAcceptableLoc >= originalX) { + return leftmostAcceptableLoc; + } + + return originalX; + } + + private _getEdgeColor(edge: Edge, highlightNode?: string): string { + if (highlightNode) { + if (edge.from === highlightNode) { + return color.theme; + } + if (edge.to === highlightNode) { + return color.themeDarker; + } + } + if (edge.isPlaceholder) { + return color.weak; + } + return color.grey; + } } diff --git a/frontend/src/components/SidePanel.tsx b/frontend/src/components/SidePanel.tsx index 535ee395f73..c5991f12b07 100644 --- a/frontend/src/components/SidePanel.tsx +++ b/frontend/src/components/SidePanel.tsx @@ -20,7 +20,7 @@ import CircularProgress from '@material-ui/core/CircularProgress'; import CloseIcon from '@material-ui/icons/Close'; import Resizable from 're-resizable'; import Slide from '@material-ui/core/Slide'; -import { color, commonCss } from '../Css'; +import { color, commonCss, zIndex } from '../Css'; import { stylesheet } from 'typestyle'; const css = stylesheet({ @@ -44,7 +44,7 @@ const css = stylesheet({ position: 'absolute !important' as any, right: 0, top: 0, - zIndex: 2, + zIndex: zIndex.SIDE_PANEL, }, }); diff --git a/frontend/src/components/Toolbar.tsx b/frontend/src/components/Toolbar.tsx index 903085b8355..a5a0b314a22 100644 --- a/frontend/src/components/Toolbar.tsx +++ b/frontend/src/components/Toolbar.tsx @@ -78,7 +78,7 @@ const css = stylesheet({ link: { $nest: { '&:hover': { - background: color.hoverBg, + background: color.lightGrey, } }, borderRadius: 3, @@ -97,7 +97,7 @@ const css = stylesheet({ justifyContent: 'space-between', }, topLevelToolbar: { - borderBottom: '1px solid #eee', + borderBottom: `1px solid ${color.lightGrey}`, paddingBottom: 15, paddingLeft: 20, }, diff --git a/frontend/src/components/UploadPipelineDialog.tsx b/frontend/src/components/UploadPipelineDialog.tsx index 9df9729923e..91cb8b8a78e 100644 --- a/frontend/src/components/UploadPipelineDialog.tsx +++ b/frontend/src/components/UploadPipelineDialog.tsx @@ -26,12 +26,12 @@ import Input from '../atoms/Input'; import InputAdornment from '@material-ui/core/InputAdornment'; import Radio from '@material-ui/core/Radio'; import { TextFieldProps } from '@material-ui/core/TextField'; -import { padding, commonCss } from '../Css'; +import { padding, commonCss, zIndex, color } from '../Css'; import { stylesheet, classes } from 'typestyle'; const css = stylesheet({ dropOverlay: { - backgroundColor: '#eee', + backgroundColor: color.lightGrey, border: '2px dashed #aaa', bottom: 0, left: 0, @@ -40,7 +40,7 @@ const css = stylesheet({ right: 0, textAlign: 'center', top: 0, - zIndex: 1, + zIndex: zIndex.DROP_ZONE_OVERLAY, }, root: { width: 500, diff --git a/frontend/src/components/__snapshots__/Graph.test.tsx.snap b/frontend/src/components/__snapshots__/Graph.test.tsx.snap index 60b6d75c279..cdb1dab5ae7 100644 --- a/frontend/src/components/__snapshots__/Graph.test.tsx.snap +++ b/frontend/src/components/__snapshots__/Graph.test.tsx.snap @@ -8,6 +8,8 @@ exports[`Graph gracefully renders a graph with a selected node id that does not className="node graphNode" key="0" onClick={[Function]} + onMouseEnter={[Function]} + onMouseLeave={[Function]} style={ Object { "backgroundColor": undefined, @@ -27,6 +29,11 @@ exports[`Graph gracefully renders a graph with a selected node id that does not
-
-
+ /> +
+
`; @@ -124,6 +153,8 @@ exports[`Graph renders a complex graph with six nodes and seven edges 1`] = ` className="node graphNode" key="0" onClick={[Function]} + onMouseEnter={[Function]} + onMouseLeave={[Function]} style={ Object { "backgroundColor": undefined, @@ -143,6 +174,11 @@ exports[`Graph renders a complex graph with six nodes and seven edges 1`] = `
+
+
@@ -348,28 +445,54 @@ exports[`Graph renders a complex graph with six nodes and seven edges 1`] = ` key="0" style={ Object { - "borderTopColor": undefined, - "borderTopStyle": "solid", - "left": 74.57233047033631, - "top": 22.5, - "transform": "translate(100px, 44px) rotate(-135deg)", + "backgroundColor": "#5f6368", + "height": 2, + "left": 54.44597636009023, + "top": 65, + "transform": "rotate(-169.35712925529643deg)", "transition": "left 0.5s, top 0.5s", - "width": 35.85533905932738, + "width": 152.10804727981954, } } />
+
+
@@ -382,28 +505,54 @@ exports[`Graph renders a complex graph with six nodes and seven edges 1`] = ` key="0" style={ Object { - "borderTopColor": undefined, - "borderTopStyle": "solid", - "left": 32.25, - "top": 82.5, - "transform": "translate(100px, 44px) rotate(-90deg)", + "backgroundColor": "#5f6368", + "height": 2, + "left": 24.12512781199456, + "top": 125, + "transform": "rotate(-166.75948008481282deg)", "transition": "left 0.5s, top 0.5s", - "width": 25.5, + "width": 122.74974437601087, } } />
+
+
@@ -416,13 +565,13 @@ exports[`Graph renders a complex graph with six nodes and seven edges 1`] = ` key="0" style={ Object { - "borderTopColor": undefined, - "borderTopStyle": "solid", - "left": 0.375, - "top": 81.875, - "transform": "translate(100px, 44px) rotate(-36.86989764584402deg)", + "backgroundColor": "#5f6368", + "height": 2, + "left": 23.342363464399483, + "top": 125, + "transform": "rotate(-160.48412699041728deg)", "transition": "left 0.5s, top 0.5s", - "width": 44.25, + "width": 84.31527307120105, } } /> @@ -431,11 +580,11 @@ exports[`Graph renders a complex graph with six nodes and seven edges 1`] = ` key="1" style={ Object { - "borderTopColor": undefined, - "borderTopStyle": "solid", - "left": -10.25, - "top": 110, - "transform": "translate(100px, 44px) rotate(-90deg)", + "backgroundColor": "#5f6368", + "height": 2, + "left": 89.75, + "top": 154, + "transform": "rotate(-90deg)", "transition": "left 0.5s, top 0.5s", "width": 30.5, } @@ -446,28 +595,54 @@ exports[`Graph renders a complex graph with six nodes and seven edges 1`] = ` key="2" style={ Object { - "borderTopColor": undefined, - "borderTopStyle": "solid", - "left": -10.25, - "top": 140, - "transform": "translate(100px, 44px) rotate(-90deg)", + "backgroundColor": "#5f6368", + "height": 2, + "left": 89.75, + "top": 184, + "transform": "rotate(-90deg)", "transition": "left 0.5s, top 0.5s", "width": 30.5, } } />
+
+
@@ -480,13 +655,13 @@ exports[`Graph renders a complex graph with six nodes and seven edges 1`] = ` key="0" style={ Object { - "borderTopColor": undefined, - "borderTopStyle": "solid", - "left": 45.375, - "top": 81.875, - "transform": "translate(100px, 44px) rotate(-143.13010235415598deg)", + "backgroundColor": "#5f6368", + "height": 2, + "left": 24.52670720293925, + "top": 125, + "transform": "rotate(-170.01257842498643deg)", "transition": "left 0.5s, top 0.5s", - "width": 44.25, + "width": 161.9465855941215, } } /> @@ -495,11 +670,11 @@ exports[`Graph renders a complex graph with six nodes and seven edges 1`] = ` key="1" style={ Object { - "borderTopColor": undefined, - "borderTopStyle": "solid", - "left": 69.75, - "top": 110, - "transform": "translate(100px, 44px) rotate(-90deg)", + "backgroundColor": "#5f6368", + "height": 2, + "left": 169.75, + "top": 154, + "transform": "rotate(-90deg)", "transition": "left 0.5s, top 0.5s", "width": 30.5, } @@ -510,28 +685,54 @@ exports[`Graph renders a complex graph with six nodes and seven edges 1`] = ` key="2" style={ Object { - "borderTopColor": undefined, - "borderTopStyle": "solid", - "left": 69.75, - "top": 140, - "transform": "translate(100px, 44px) rotate(-90deg)", + "backgroundColor": "#5f6368", + "height": 2, + "left": 169.75, + "top": 184, + "transform": "rotate(-90deg)", "transition": "left 0.5s, top 0.5s", "width": 30.5, } } />
+
+
@@ -544,28 +745,54 @@ exports[`Graph renders a complex graph with six nodes and seven edges 1`] = ` key="0" style={ Object { - "borderTopColor": undefined, - "borderTopStyle": "solid", - "left": 32.25, - "top": 142.5, - "transform": "translate(100px, 44px) rotate(-90deg)", + "backgroundColor": "#5f6368", + "height": 2, + "left": 24.12512781199456, + "top": 185, + "transform": "rotate(-166.75948008481282deg)", "transition": "left 0.5s, top 0.5s", - "width": 25.5, + "width": 122.74974437601087, } } />
+
+
@@ -578,109 +805,58 @@ exports[`Graph renders a complex graph with six nodes and seven edges 1`] = ` key="0" style={ Object { - "borderTopColor": undefined, - "borderTopStyle": "solid", - "left": 46.50406530937789, - "top": 141.25, - "transform": "translate(100px, 44px) rotate(-153.43494882292202deg)", + "backgroundColor": "#5f6368", + "height": 2, + "left": 24.661645340032806, + "top": 185, + "transform": "rotate(-171.10957674263614deg)", "transition": "left 0.5s, top 0.5s", - "width": 61.991869381244214, + "width": 181.6767093199344, } } />
-
-
-
-
-
-
-
-
+
+ /> +
`; @@ -692,6 +868,8 @@ exports[`Graph renders a graph with a placeholder node and edge 1`] = ` className="node graphNode" key="0" onClick={[Function]} + onMouseEnter={[Function]} + onMouseLeave={[Function]} style={ Object { "backgroundColor": undefined, @@ -711,6 +889,11 @@ exports[`Graph renders a graph with a placeholder node and edge 1`] = `
-
- node2 -
@@ -773,28 +958,17 @@ exports[`Graph renders a graph with a placeholder node and edge 1`] = ` key="1" style={ Object { - "borderTopColor": undefined, - "borderTopStyle": "dotted", - "left": -7.75, - "top": 47.5, - "transform": "translate(100px, 44px) rotate(-90deg)", + "backgroundColor": "#9aa0a6", + "height": 2, + "left": 92.25, + "top": 91.5, + "transform": "rotate(-90deg)", "transition": "left 0.5s, top 0.5s", "width": 25.5, } } />
-
`; @@ -806,6 +980,8 @@ exports[`Graph renders a graph with a selected node 1`] = ` className="node graphNode nodeSelected" key="0" onClick={[Function]} + onMouseEnter={[Function]} + onMouseLeave={[Function]} style={ Object { "backgroundColor": undefined, @@ -825,6 +1001,11 @@ exports[`Graph renders a graph with a selected node 1`] = `
-
-
+ /> +
+
`; @@ -920,6 +1123,8 @@ exports[`Graph renders a graph with colored edges 1`] = ` className="node graphNode" key="0" onClick={[Function]} + onMouseEnter={[Function]} + onMouseLeave={[Function]} style={ Object { "backgroundColor": undefined, @@ -939,6 +1144,11 @@ exports[`Graph renders a graph with colored edges 1`] = `
-
-
+ /> +
+
`; @@ -1034,6 +1266,8 @@ exports[`Graph renders a graph with colored nodes 1`] = ` className="node graphNode" key="0" onClick={[Function]} + onMouseEnter={[Function]} + onMouseLeave={[Function]} style={ Object { "backgroundColor": "red", @@ -1053,6 +1287,11 @@ exports[`Graph renders a graph with colored nodes 1`] = `
-
-
+ /> +
+
`; @@ -1255,6 +1537,8 @@ exports[`Graph renders a graph with two connectd nodes in reverse order 1`] = ` className="node graphNode" key="0" onClick={[Function]} + onMouseEnter={[Function]} + onMouseLeave={[Function]} style={ Object { "backgroundColor": undefined, @@ -1274,6 +1558,11 @@ exports[`Graph renders a graph with two connectd nodes in reverse order 1`] = `
-
-
+ /> +
+
`; @@ -1369,6 +1680,8 @@ exports[`Graph renders a graph with two disparate nodes 1`] = ` className="node graphNode" key="0" onClick={[Function]} + onMouseEnter={[Function]} + onMouseLeave={[Function]} style={ Object { "backgroundColor": undefined, @@ -1388,6 +1701,11 @@ exports[`Graph renders a graph with two disparate nodes 1`] = `
@@ -169,7 +169,7 @@ exports[`PlotCard close button closes full screen dialog 1`] = ` @@ -268,7 +268,7 @@ exports[`PlotCard pops out a full screen view of the viewer 1`] = ` @@ -367,7 +367,7 @@ exports[`PlotCard renders on confusion matrix viewer card 1`] = ` diff --git a/frontend/src/lib/Constants.ts b/frontend/src/lib/Constants.ts new file mode 100644 index 00000000000..70aabe43d8b --- /dev/null +++ b/frontend/src/lib/Constants.ts @@ -0,0 +1,21 @@ +/* + * Copyright 2019 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://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. + */ + +// tslint:disable-next-line:variable-name +export const Constants = { + NODE_HEIGHT: 64, + NODE_WIDTH: 172, +}; diff --git a/frontend/src/lib/StaticGraphParser.ts b/frontend/src/lib/StaticGraphParser.ts index cd28e0ed9f3..847e3727618 100644 --- a/frontend/src/lib/StaticGraphParser.ts +++ b/frontend/src/lib/StaticGraphParser.ts @@ -15,7 +15,9 @@ */ import * as dagre from 'dagre'; +import { Constants } from './Constants'; import { Workflow, Template } from '../../third_party/argo-ui/argo_template'; +import { color } from '../Css'; import { logger } from './Utils'; export type nodeType = 'container' | 'dag' | 'unknown'; @@ -40,10 +42,6 @@ export class SelectedNodeInfo { } } - -const NODE_WIDTH = 180; -const NODE_HEIGHT = 70; - export function _populateInfoFromTemplate(info: SelectedNodeInfo, template?: Template): SelectedNodeInfo { if (!template || !template.container) { return info; @@ -131,10 +129,10 @@ function buildDag( graph.setNode(nodeId, { bgColor: task.when ? 'cornsilk' : undefined, - height: NODE_HEIGHT, + height: Constants.NODE_HEIGHT, info, label: task.template, - width: NODE_WIDTH, + width: Constants.NODE_WIDTH, }); } @@ -169,11 +167,11 @@ export function createGraph(workflow: Workflow): dagre.graphlib.Graph { const info = new SelectedNodeInfo(); _populateInfoFromTemplate(info, template); graph.setNode(template.name, { - bgColor: '#eee', - height: NODE_HEIGHT, + bgColor: color.lightGrey, + height: Constants.NODE_HEIGHT, info, label: 'onExit - ' + template.name, - width: NODE_WIDTH, + width: Constants.NODE_WIDTH, }); } @@ -195,9 +193,9 @@ export function createGraph(workflow: Workflow): dagre.graphlib.Graph { const entryPointTemplate = workflowTemplates.find((t) => t.name === workflow.spec.entrypoint); if (entryPointTemplate) { graph.setNode(entryPointTemplate.name, { - height: NODE_HEIGHT, + height: Constants.NODE_HEIGHT, label: entryPointTemplate.name, - width: NODE_WIDTH, + width: Constants.NODE_WIDTH, }); } } diff --git a/frontend/src/lib/WorkflowParser.test.ts b/frontend/src/lib/WorkflowParser.test.ts index 51a5e13e13f..7001409f25f 100644 --- a/frontend/src/lib/WorkflowParser.test.ts +++ b/frontend/src/lib/WorkflowParser.test.ts @@ -17,6 +17,7 @@ import WorkflowParser, { StorageService } from './WorkflowParser'; import { NodePhase } from '../pages/Status'; import { color } from '../Css'; +import { Constants } from './Constants'; describe('WorkflowParser', () => { describe('createRuntimeGraph', () => { @@ -194,8 +195,8 @@ describe('WorkflowParser', () => { const g = WorkflowParser.createRuntimeGraph(workflow as any); const runningNode = g.node('runningNode'); - expect(runningNode.height).toEqual(70); - expect(runningNode.width).toEqual(180); + expect(runningNode.height).toEqual(Constants.NODE_HEIGHT); + expect(runningNode.width).toEqual(Constants.NODE_WIDTH); expect(runningNode.label).toEqual('runningNode'); expect(runningNode.isPlaceholder).toBeUndefined(); diff --git a/frontend/src/lib/WorkflowParser.ts b/frontend/src/lib/WorkflowParser.ts index 519dbb21b28..52eb497e873 100644 --- a/frontend/src/lib/WorkflowParser.ts +++ b/frontend/src/lib/WorkflowParser.ts @@ -18,8 +18,9 @@ import * as dagre from 'dagre'; import IconWithTooltip from '../atoms/IconWithTooltip'; import MoreIcon from '@material-ui/icons/MoreHoriz'; import { Workflow, NodeStatus, Parameter } from '../../third_party/argo-ui/argo_template'; -import { statusToIcon, NodePhase, hasFinished } from '../pages/Status'; +import { statusToIcon, NodePhase, hasFinished, statusToBgColor } from '../pages/Status'; import { color } from '../Css'; +import { Constants } from './Constants'; export enum StorageService { GCS = 'gcs', @@ -38,8 +39,6 @@ export default class WorkflowParser { g.setGraph({}); g.setDefaultEdgeLabel(() => ({})); - const NODE_WIDTH = 180; - const NODE_HEIGHT = 70; const PLACEHOLDER_NODE_DIMENSION = 28; if (!workflow || !workflow.status || !workflow.status.nodes || @@ -73,10 +72,11 @@ export default class WorkflowParser { (Object as any).values(workflowNodes) .forEach((node: NodeStatus) => { g.setNode(node.id, { - height: NODE_HEIGHT, + height: Constants.NODE_HEIGHT, icon: statusToIcon(node.phase as NodePhase, node.startedAt, node.finishedAt), label: node.displayName || node.id, - width: NODE_WIDTH, + statusColoring: statusToBgColor(node.phase as NodePhase), + width: Constants.NODE_WIDTH, ...node, }); diff --git a/frontend/src/pages/ExperimentDetails.tsx b/frontend/src/pages/ExperimentDetails.tsx index 39c281ad368..9a9c3a6fc47 100644 --- a/frontend/src/pages/ExperimentDetails.tsx +++ b/frontend/src/pages/ExperimentDetails.tsx @@ -64,7 +64,7 @@ const css = stylesheet({ lineHeight: '28px', }, cardRow: { - borderBottom: '1px solid #eee', + borderBottom: `1px solid ${color.lightGrey}`, display: 'flex', flexFlow: 'row', minHeight: 120, diff --git a/frontend/src/pages/PipelineDetails.tsx b/frontend/src/pages/PipelineDetails.tsx index 68a6ec1cacb..746781e8dd6 100644 --- a/frontend/src/pages/PipelineDetails.tsx +++ b/frontend/src/pages/PipelineDetails.tsx @@ -38,7 +38,7 @@ import { URLParser } from '../lib/URLParser'; import { UnControlled as CodeMirror } from 'react-codemirror2'; import { Workflow } from '../../third_party/argo-ui/argo_template'; import { classes, stylesheet } from 'typestyle'; -import { color, commonCss, padding, fontsize, fonts } from '../Css'; +import { color, commonCss, padding, fontsize, fonts, zIndex } from '../Css'; import { logger, formatDateString } from '../lib/Utils'; interface PipelineDetailsState { @@ -90,7 +90,7 @@ export const css = stylesheet({ padding: 10, position: 'absolute', width: summaryCardWidth, - zIndex: 1, + zIndex: zIndex.PIPELINE_SUMMARY_CARD, }, summaryKey: { color: color.strong, @@ -200,7 +200,7 @@ class PipelineDetails extends Page<{}, PipelineDetailsState> { {!summaryShown && ( + )}
diff --git a/frontend/src/pages/Status.test.tsx b/frontend/src/pages/Status.test.tsx index b078003c643..eda02059915 100644 --- a/frontend/src/pages/Status.test.tsx +++ b/frontend/src/pages/Status.test.tsx @@ -15,8 +15,8 @@ */ import * as Utils from '../lib/Utils'; +import { NodePhase, hasFinished, statusBgColors, statusToBgColor, statusToIcon } from './Status'; import { shallow } from 'enzyme'; -import { statusToIcon, NodePhase, hasFinished } from './Status'; describe('Status', () => { @@ -35,14 +35,14 @@ describe('Status', () => { describe('statusToIcon', () => { it('handles an unknown phase', () => { - const consoleSpy = jest.spyOn(console, 'log').mockImplementation(() => null); + const consoleSpy = jest.spyOn(console, 'log').mockImplementationOnce(() => null); const tree = shallow(statusToIcon('bad phase' as any)); expect(tree).toMatchSnapshot(); expect(consoleSpy).toHaveBeenLastCalledWith('Unknown node phase:', 'bad phase'); }); it('handles an undefined phase', () => { - const consoleSpy = jest.spyOn(console, 'log').mockImplementation(() => null); + const consoleSpy = jest.spyOn(console, 'log').mockImplementationOnce(() => null); const tree = shallow(statusToIcon(/* no phase */)); expect(tree).toMatchSnapshot(); expect(consoleSpy).toHaveBeenLastCalledWith('Unknown node phase:', undefined); @@ -92,5 +92,51 @@ describe('Status', () => { it('returns \'false\' if status is undefined', () => { expect(hasFinished(undefined)).toBe(false); }); + + it('returns \'false\' if status is invalid', () => { + expect(hasFinished('bad phase' as any)).toBe(false); + }); + }); + + describe('statusToBgColor', () => { + it('handles an invalid phase', () => { + const consoleSpy = jest.spyOn(console, 'log').mockImplementationOnce(() => null); + expect(statusToBgColor('bad phase' as any)).toEqual(statusBgColors.notStarted); + expect(consoleSpy).toHaveBeenLastCalledWith('Unknown node phase:', 'bad phase'); + }); + + it('handles an \'Unknown\' phase', () => { + const consoleSpy = jest.spyOn(console, 'log').mockImplementationOnce(() => null); + expect(statusToBgColor(NodePhase.UNKNOWN)).toEqual(statusBgColors.notStarted); + expect(consoleSpy).toHaveBeenLastCalledWith('Unknown node phase:', 'Unknown'); + }); + + it('returns color \'not started\' if status is undefined', () => { + const consoleSpy = jest.spyOn(console, 'log').mockImplementationOnce(() => null); + expect(statusToBgColor(undefined)).toEqual(statusBgColors.notStarted); + expect(consoleSpy).toHaveBeenLastCalledWith('Unknown node phase:', undefined); + }); + + it('returns color \'not started\' if status is \'Pending\'', () => { + expect(statusToBgColor(NodePhase.PENDING)).toEqual(statusBgColors.notStarted); + }); + + [NodePhase.ERROR, NodePhase.FAILED].forEach(status => { + it(`returns color \'error\' if status is: ${status}`, () => { + expect(statusToBgColor(status)).toEqual(statusBgColors.error); + }); + }); + + it('returns color \'running\' if status is \'Running\'', () => { + expect(statusToBgColor(NodePhase.RUNNING)).toEqual(statusBgColors.running); + }); + + it('returns color \'stop or skip\' if status is \'Skipped\'', () => { + expect(statusToBgColor(NodePhase.SKIPPED)).toEqual(statusBgColors.stopOrSkip); + }); + + it('returns color \'succeeded\' if status is \'Succeeded\'', () => { + expect(statusToBgColor(NodePhase.SUCCEEDED)).toEqual(statusBgColors.succeeded); + }); }); }); diff --git a/frontend/src/pages/Status.tsx b/frontend/src/pages/Status.tsx index 48fb76dcd1a..636d8043039 100644 --- a/frontend/src/pages/Status.tsx +++ b/frontend/src/pages/Status.tsx @@ -25,6 +25,15 @@ import UnknownIcon from '@material-ui/icons/Help'; import { color } from '../Css'; import { logger, formatDateString } from '../lib/Utils'; +export const statusBgColors = { + error: '#fce8e6', + notStarted: '#f7f7f7', + running: '#e8f0fe', + stopOrSkip: '#f1f3f4', + succeeded: '#e6f4ea', + warning: '#fef7f0', +}; + export enum NodePhase { ERROR = 'Error', FAILED = 'Failed', @@ -36,10 +45,6 @@ export enum NodePhase { } export function hasFinished(status?: NodePhase): boolean { - if (!status) { - return false; - } - switch (status) { case NodePhase.SUCCEEDED: // Fall through case NodePhase.FAILED: // Fall through @@ -55,6 +60,28 @@ export function hasFinished(status?: NodePhase): boolean { } } +export function statusToBgColor(status?: NodePhase): string { + switch (status) { + case NodePhase.ERROR: + // fall through + case NodePhase.FAILED: + return statusBgColors.error; + case NodePhase.PENDING: + return statusBgColors.notStarted; + case NodePhase.RUNNING: + return statusBgColors.running; + case NodePhase.SKIPPED: + return statusBgColors.stopOrSkip; + case NodePhase.SUCCEEDED: + return statusBgColors.succeeded; + case NodePhase.UNKNOWN: + // fall through + default: + logger.verbose('Unknown node phase:', status); + return statusBgColors.notStarted; + } +} + export function statusToIcon(status?: NodePhase, startDate?: Date | string, endDate?: Date | string): JSX.Element { // tslint:disable-next-line:variable-name let IconComponent: any = UnknownIcon; @@ -78,7 +105,7 @@ export function statusToIcon(status?: NodePhase, startDate?: Date | string, endD break; case NodePhase.RUNNING: IconComponent = RunningIcon; - iconColor = color.success; + iconColor = color.blue; title = 'Running'; break; case NodePhase.SKIPPED: diff --git a/frontend/src/pages/__snapshots__/ExperimentList.test.tsx.snap b/frontend/src/pages/__snapshots__/ExperimentList.test.tsx.snap index df1107313bb..c12ca3d2248 100644 --- a/frontend/src/pages/__snapshots__/ExperimentList.test.tsx.snap +++ b/frontend/src/pages/__snapshots__/ExperimentList.test.tsx.snap @@ -233,7 +233,7 @@ exports[`ExperimentList renders last 5 runs statuses 1`] = `