From 70b5605e0a1b436516abc2a3bc8e78174e694651 Mon Sep 17 00:00:00 2001 From: Alan Greene Date: Tue, 10 Dec 2019 15:43:55 +0000 Subject: [PATCH] Add ZoomablePipelineGraph component Add wrapper component for PipelineGraph that enables pan/zoom functionality. This is the first pass to get the basic functionality delivered. Subsequent PRs will build on this to refine the experience. Known issues: - clicks to expand/collapse nodes are not currently handled correctly - some minor layout issues with 'thin' pipelines (very wide / very long) - styling needs to be applied to container and controls - missing full set of controls as per design --- package-lock.json | 58 ++++---- packages/components/package.json | 1 + .../components/src/components/Graph/Graph.js | 8 +- .../src/components/Graph/Graph.stories.js | 23 +++- .../components/src/components/Graph/Node.js | 2 - .../src/components/Graph/PanZoom.js | 124 ++++++++++++++++++ .../src/components/Graph/PipelineGraph.js | 2 - .../components/Graph/ZoomablePipelineGraph.js | 94 +++++++++++++ .../Graph/ZoomablePipelineGraph.scss | 35 +++++ .../src/components/Graph/examples/graph.json | 28 ++-- 10 files changed, 315 insertions(+), 60 deletions(-) create mode 100644 packages/components/src/components/Graph/PanZoom.js create mode 100644 packages/components/src/components/Graph/ZoomablePipelineGraph.js create mode 100644 packages/components/src/components/Graph/ZoomablePipelineGraph.scss diff --git a/package-lock.json b/package-lock.json index 76bb90ef6..c3ec3ba4d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5040,6 +5040,7 @@ "@formatjs/intl-pluralrules": "^1.2.1", "@formatjs/intl-relativetimeformat": "^4.1.1", "@tektoncd/dashboard-utils": "file:packages/utils", + "@vx/event": "0.0.192", "@vx/network": "0.0.192", "ansi-to-react": "^5.0.0", "carbon-components": "^10.4.1", @@ -5053,8 +5054,7 @@ "dependencies": { "@formatjs/intl-relativetimeformat": { "version": "4.1.1", - "resolved": "https://registry.npmjs.org/@formatjs/intl-relativetimeformat/-/intl-relativetimeformat-4.1.1.tgz", - "integrity": "sha512-k+WADErd1ORTcJ/4LyrVzJcXkcXL3l9wKCsMT2d7Kq4dbap2OKv2GLBGdKR3H7SBKCooyDRV5W79et9egGzsyQ==", + "bundled": true, "requires": { "@formatjs/intl-utils": "^1.3.0" } @@ -5240,6 +5240,14 @@ "integrity": "sha512-gCubfBUZ6KxzoibJ+SCUc/57Ms1jz5NjHe4+dI2krNmU5zCPAphyLJYyTOg06ueIyfj+SaCUqmzun7ImlxDcKg==", "dev": true }, + "@vx/event": { + "version": "0.0.192", + "resolved": "https://registry.npmjs.org/@vx/event/-/event-0.0.192.tgz", + "integrity": "sha512-PJ2gO+O1SXMD7MPJmmXLwih+KSQ95flgJYIskJLyy73yc2my3+Uzt0jsU1GAvKvkIC2KozYpQfJ6hOOBoQNwzw==", + "requires": { + "@vx/point": "0.0.192" + } + }, "@vx/group": { "version": "0.0.192", "resolved": "https://registry.npmjs.org/@vx/group/-/group-0.0.192.tgz", @@ -5260,6 +5268,11 @@ "prop-types": "^15.6.2" } }, + "@vx/point": { + "version": "0.0.192", + "resolved": "https://registry.npmjs.org/@vx/point/-/point-0.0.192.tgz", + "integrity": "sha512-kIWYZcTvw95bCD5A4dsyqJYn9oDJxvKuw+DBjp3/X/m7+IAQ6rpo41MkDDjFFyu7napZJM6h3xwxj8SxOyJGmw==" + }, "@webassemblyjs/ast": { "version": "1.8.5", "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.8.5.tgz", @@ -11061,8 +11074,7 @@ "ansi-regex": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.1.1.tgz", - "integrity": "sha1-w7M6te42DYbg5ijwRorn7yfWVN8=", - "optional": true + "integrity": "sha1-w7M6te42DYbg5ijwRorn7yfWVN8=" }, "aproba": { "version": "1.2.0", @@ -11083,14 +11095,12 @@ "balanced-match": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.0.tgz", - "integrity": "sha1-ibTRmasr7kneFk6gK4nORi1xt2c=", - "optional": true + "integrity": "sha1-ibTRmasr7kneFk6gK4nORi1xt2c=" }, "brace-expansion": { "version": "1.1.11", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", - "optional": true, "requires": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -11105,20 +11115,17 @@ "code-point-at": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/code-point-at/-/code-point-at-1.1.0.tgz", - "integrity": "sha1-DQcLTQQ6W+ozovGkDi7bPZpMz3c=", - "optional": true + "integrity": "sha1-DQcLTQQ6W+ozovGkDi7bPZpMz3c=" }, "concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=", - "optional": true + "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=" }, "console-control-strings": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz", - "integrity": "sha1-PXz0Rk22RG6mRL9LOVB/mFEAjo4=", - "optional": true + "integrity": "sha1-PXz0Rk22RG6mRL9LOVB/mFEAjo4=" }, "core-util-is": { "version": "1.0.2", @@ -11235,8 +11242,7 @@ "inherits": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz", - "integrity": "sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4=", - "optional": true + "integrity": "sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4=" }, "ini": { "version": "1.3.5", @@ -11248,7 +11254,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-1.0.0.tgz", "integrity": "sha1-754xOG8DGn8NZDr4L95QxFfvAMs=", - "optional": true, "requires": { "number-is-nan": "^1.0.0" } @@ -11263,7 +11268,6 @@ "version": "3.0.4", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz", "integrity": "sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==", - "optional": true, "requires": { "brace-expansion": "^1.1.7" } @@ -11271,14 +11275,12 @@ "minimist": { "version": "0.0.8", "resolved": "https://registry.npmjs.org/minimist/-/minimist-0.0.8.tgz", - "integrity": "sha1-hX/Kv8M5fSYluCKCYuhqp6ARsF0=", - "optional": true + "integrity": "sha1-hX/Kv8M5fSYluCKCYuhqp6ARsF0=" }, "minipass": { "version": "2.3.5", "resolved": "https://registry.npmjs.org/minipass/-/minipass-2.3.5.tgz", "integrity": "sha512-Gi1W4k059gyRbyVUZQ4mEqLm0YIUiGYfvxhF6SIlk3ui1WVxMTGfGdQ2SInh3PDrRTVvPKgULkpJtT4RH10+VA==", - "optional": true, "requires": { "safe-buffer": "^5.1.2", "yallist": "^3.0.0" @@ -11297,7 +11299,6 @@ "version": "0.5.1", "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.1.tgz", "integrity": "sha1-MAV0OOrGz3+MR2fzhkjWaX11yQM=", - "optional": true, "requires": { "minimist": "0.0.8" } @@ -11378,8 +11379,7 @@ "number-is-nan": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/number-is-nan/-/number-is-nan-1.0.1.tgz", - "integrity": "sha1-CXtgK1NCKlIsGvuHkDGDNpQaAR0=", - "optional": true + "integrity": "sha1-CXtgK1NCKlIsGvuHkDGDNpQaAR0=" }, "object-assign": { "version": "4.1.1", @@ -11391,7 +11391,6 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", "integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=", - "optional": true, "requires": { "wrappy": "1" } @@ -11477,8 +11476,7 @@ "safe-buffer": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", - "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", - "optional": true + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" }, "safer-buffer": { "version": "2.1.2", @@ -11514,7 +11512,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/string-width/-/string-width-1.0.2.tgz", "integrity": "sha1-EYvfW4zcUaKn5w0hHgfisLmxB9M=", - "optional": true, "requires": { "code-point-at": "^1.0.0", "is-fullwidth-code-point": "^1.0.0", @@ -11534,7 +11531,6 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz", "integrity": "sha1-ajhfuIU9lS1f8F0Oiq+UJ43GPc8=", - "optional": true, "requires": { "ansi-regex": "^2.0.0" } @@ -11578,14 +11574,12 @@ "wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=", - "optional": true + "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=" }, "yallist": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.0.3.tgz", - "integrity": "sha512-S+Zk8DEWE6oKpV+vI3qWkaK+jSbIK86pCwe2IF/xwIpQ8jEuxpw9NyaGjmp9+BoJv5FV2piqCDcoCtStppiq2A==", - "optional": true + "integrity": "sha512-S+Zk8DEWE6oKpV+vI3qWkaK+jSbIK86pCwe2IF/xwIpQ8jEuxpw9NyaGjmp9+BoJv5FV2piqCDcoCtStppiq2A==" } } }, diff --git a/packages/components/package.json b/packages/components/package.json index 41d22bf31..b0a22112c 100644 --- a/packages/components/package.json +++ b/packages/components/package.json @@ -14,6 +14,7 @@ "@formatjs/intl-pluralrules": "^1.2.1", "@formatjs/intl-relativetimeformat": "^4.1.1", "@tektoncd/dashboard-utils": "file:../utils", + "@vx/event": "0.0.192", "@vx/network": "0.0.192", "ansi-to-react": "^5.0.0", "carbon-components": "^10.4.1", diff --git a/packages/components/src/components/Graph/Graph.js b/packages/components/src/components/Graph/Graph.js index d379b49fd..02ff6bb15 100644 --- a/packages/components/src/components/Graph/Graph.js +++ b/packages/components/src/components/Graph/Graph.js @@ -60,15 +60,17 @@ export default class Graph extends Component { .then(g => { this.setState({ links: g.edges, - nodes: g.children + nodes: g.children, + width: g.width, + height: g.height }); }) .catch(console.error); // eslint-disable-line no-console }; render() { - const { height, isSubGraph, onClickStep, onClickTask, width } = this.props; - const { links, margin, nodes } = this.state; + const { isSubGraph, onClickStep, onClickTask } = this.props; + const { height, links, margin, nodes, width } = this.state; if (!nodes) { return null; diff --git a/packages/components/src/components/Graph/Graph.stories.js b/packages/components/src/components/Graph/Graph.stories.js index c8e60f4ab..cadf44cd0 100644 --- a/packages/components/src/components/Graph/Graph.stories.js +++ b/packages/components/src/components/Graph/Graph.stories.js @@ -18,6 +18,7 @@ import { action } from '@storybook/addon-actions'; import Node from './Node'; import Graph from './Graph'; import PipelineGraph from './PipelineGraph'; +import ZoomablePipelineGraph from './ZoomablePipelineGraph'; import graph from './examples/graph.json'; import pipeline from './examples/pipeline.json'; @@ -40,7 +41,7 @@ import tasks from './examples/tasks.json'; const taskProps = { type: 'Task', label: 'build-and-push', - width: 160, + width: 200, height: 30 }; @@ -50,19 +51,19 @@ const expandedProps = { type: 'Step', id: '__step_build-and-push__build-image', label: 'build-image', - width: 160, + width: 200, height: 30 }, { type: 'Step', id: '__step_build-and-push__push-image', label: 'push-image', - width: 160, + width: 200, height: 30 } ], edges: [], - height: 79 + height: 90 }; storiesOf('Graph/Node', module) @@ -82,9 +83,7 @@ storiesOf('Graph/Node', module) )); -storiesOf('Graph/Graph', module).add('default', () => ( - -)); +storiesOf('Graph/Graph', module).add('default', () => ); storiesOf('Graph/PipelineGraph', module).add('default', () => ( ( tasks={tasks} /> )); + +storiesOf('Graph/ZoomablePipelineGraph', module).add('default', () => ( + +)); diff --git a/packages/components/src/components/Graph/Node.js b/packages/components/src/components/Graph/Node.js index 929cb961d..e8fefd74b 100644 --- a/packages/components/src/components/Graph/Node.js +++ b/packages/components/src/components/Graph/Node.js @@ -133,8 +133,6 @@ export default class Node extends Component { graph={{ id: `${id}_subgraph`, children, edges }} isSubGraph onClickStep={this.handleClickStep} - width={width} - height={height} /> )} diff --git a/packages/components/src/components/Graph/PanZoom.js b/packages/components/src/components/Graph/PanZoom.js new file mode 100644 index 000000000..88bacd573 --- /dev/null +++ b/packages/components/src/components/Graph/PanZoom.js @@ -0,0 +1,124 @@ +/* +Copyright 2019 The Tekton Authors +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +/* istanbul ignore file */ +import React from 'react'; +import { localPoint } from '@vx/event'; + +const initialState = { + dragging: false, + scale: 1, + translate: { + x: 0, + y: 0 + } +}; + +export default class PanZoom extends React.Component { + state = initialState; + + reset = () => { + this.setState(initialState); + }; + + center = () => { + this.setState({ + translate: { + x: 0, + y: 0 + } + }); + }; + + zoomIn = () => { + this.setState(prevState => ({ + scale: prevState.scale * 1.25 + })); + }; + + zoomOut = () => { + this.setState(prevState => ({ + scale: prevState.scale * 0.8 + })); + }; + + zoomScroll = event => { + event.preventDefault(); + const scaleBy = event.deltaY > 0 ? 1.25 : 0.8; + this.setState(prevState => ({ + scale: prevState.scale * scaleBy + })); + }; + + setZoom = scale => { + this.setState({ + scale + }); + }; + + dragStart = event => { + console.log({ event }); + this.setState({ dragging: true }); + this.startPoint = localPoint(this.props.svg(), event); + this.startTranslate = this.state.translate; + }; + + dragEnd = () => { + this.setState({ dragging: false }); + }; + + dragMove = event => { + if (!this.state.dragging) { + return; + } + + const endPoint = localPoint(this.props.svg(), event); + const deltaX = endPoint.x - this.startPoint.x; + const deltaY = endPoint.y - this.startPoint.y; + + this.setState(prevState => ({ + translate: { + x: this.startTranslate.x + deltaX / prevState.scale, + y: this.startTranslate.y + deltaY / prevState.scale + } + })); + }; + + render() { + const { width, height, children } = this.props; + const { translate, scale } = this.state; + + const center = { x: width / 2, y: height / 2 }; + + const newTranslate = { + x: translate.x * scale + center.x - center.x * scale, + y: translate.y * scale + center.y - center.y * scale + }; + + return children({ + scale, + translate: newTranslate, + + dragStart: this.dragStart, + dragMove: this.dragMove, + dragEnd: this.dragEnd, + + zoomIn: this.zoomIn, + zoomOut: this.zoomOut, + zoomScroll: this.zoomScroll, + setZoom: this.setZoom, + + reset: this.reset, + center: this.center + }); + } +} diff --git a/packages/components/src/components/Graph/PipelineGraph.js b/packages/components/src/components/Graph/PipelineGraph.js index 1ec802543..0930595a5 100644 --- a/packages/components/src/components/Graph/PipelineGraph.js +++ b/packages/components/src/components/Graph/PipelineGraph.js @@ -57,8 +57,6 @@ export default class PipelineGraph extends Component { return graph ? ( diff --git a/packages/components/src/components/Graph/ZoomablePipelineGraph.js b/packages/components/src/components/Graph/ZoomablePipelineGraph.js new file mode 100644 index 000000000..c857369ff --- /dev/null +++ b/packages/components/src/components/Graph/ZoomablePipelineGraph.js @@ -0,0 +1,94 @@ +/* +Copyright 2019 The Tekton Authors +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ +/* istanbul ignore file */ +import React from 'react'; + +import PanZoom from './PanZoom'; +import PipelineGraph from './PipelineGraph'; + +import './ZoomablePipelineGraph.scss'; + +const width = 300; +const height = 600; + +export default class ZoomablePipelineGraph extends React.Component { + render() { + return ( + this.svg} width={width} height={height}> + {({ + translate, + scale, + dragStart, + dragMove, + dragEnd, + zoomIn, + zoomOut, + zoomScroll, + setZoom, + center, + reset + }) => ( +
+ { + this.svg = ref; + }} + onWheel={zoomScroll} + onMouseDown={dragStart} + onMouseUp={dragEnd} + onMouseMove={dragMove} + onDoubleClick={zoomIn} + > + + + + + +
+ {/* pan */} + {/* expand / collapse all */} + {/* fit to window */} + + + + setZoom(e.target.value)} + /> + +
+
+ )} +
+ ); + } +} diff --git a/packages/components/src/components/Graph/ZoomablePipelineGraph.scss b/packages/components/src/components/Graph/ZoomablePipelineGraph.scss new file mode 100644 index 000000000..8ea1e73c2 --- /dev/null +++ b/packages/components/src/components/Graph/ZoomablePipelineGraph.scss @@ -0,0 +1,35 @@ +/* +Copyright 2019 The Tekton Authors +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +@import '~carbon-components/scss/globals/scss/vars'; + +.pipeline-graph-zoom-container { + display: flex; + flex-direction: column; + align-items: center; + width: 300px; + height: 600px; + overflow: hidden; + outline: 1px solid $ui-03; + + .toolbar { + width: 100%; + align-self: bottom; + text-align: center; + background-color: $ui-03; + } + + svg { + margin: .5rem; + } +} diff --git a/packages/components/src/components/Graph/examples/graph.json b/packages/components/src/components/Graph/examples/graph.json index c58ddc0dc..72d7e0905 100644 --- a/packages/components/src/components/Graph/examples/graph.json +++ b/packages/components/src/components/Graph/examples/graph.json @@ -60,8 +60,8 @@ { "id": "__step__skaffold-unit-tests__run-tests", "label": "run-tests", - "width": 181, - "height": 26, + "width": 200, + "height": 30, "nChildren": 2, "nParents": 1, "type": "Step" @@ -91,8 +91,8 @@ { "id": "__step__build-skaffold-web__build-and-push", "label": "build-and-push", - "width": 186, - "height": 26, + "width": 200, + "height": 30, "nChildren": 1, "nParents": 1, "type": "Step" @@ -119,8 +119,8 @@ { "id": "__step__build-skaffold-app__build-and-push", "label": "build-and-push", - "width": 186, - "height": 26, + "width": 200, + "height": 30, "nChildren": 1, "nParents": 1, "type": "Step" @@ -147,8 +147,8 @@ { "id": "__step__deploy-app__replace-image", "label": "replace-image", - "width": 168, - "height": 26, + "width": 200, + "height": 30, "nChildren": 1, "nParents": 1, "type": "Step" @@ -156,8 +156,8 @@ { "id": "__step__deploy-app__run-kubectl", "label": "run-kubectl", - "width": 150, - "height": 26, + "width": 200, + "height": 30, "nChildren": 1, "nParents": 1, "type": "Step" @@ -191,8 +191,8 @@ { "id": "__step__deploy-web__replace-image", "label": "replace-image", - "width": 168, - "height": 26, + "width": 200, + "height": 30, "nChildren": 1, "nParents": 1, "type": "Step" @@ -200,8 +200,8 @@ { "id": "__step__deploy-web__run-kubectl", "label": "run-kubectl", - "width": 150, - "height": 26, + "width": 200, + "height": 30, "nChildren": 1, "nParents": 1, "type": "Step"