Canvas zoom control (#2513)

* Added a zoom slider control in the bottom-right corner.

* Made the control vertical and added the buttons.

* Adjusted the styles and borders.

* Trying to fix Webpack

* Hide zoom control when there is no content.

* Polished the code.
This commit is contained in:
Filip Barl
2017-05-15 11:49:12 +02:00
committed by GitHub
parent 963a41107c
commit 5935a32d0a
10 changed files with 277 additions and 41 deletions

View File

@@ -1,15 +1,15 @@
import React from 'react';
import { connect } from 'react-redux';
import Logo from '../components/logo';
import NodesChartElements from './nodes-chart-elements';
import ZoomWrapper from '../components/zoom-wrapper';
import ZoomableCanvas from '../components/zoomable-canvas';
import { clickBackground } from '../actions/app-actions';
import {
graphZoomLimitsSelector,
graphZoomStateSelector,
} from '../selectors/graph-view/zoom';
const EdgeMarkerDefinition = ({ selectedNodeId }) => {
const markerOffset = selectedNodeId ? '35' : '40';
const markerSize = selectedNodeId ? '10' : '30';
@@ -47,16 +47,14 @@ class NodesChart extends React.Component {
const { selectedNodeId } = this.props;
return (
<div className="nodes-chart">
<svg id="canvas" width="100%" height="100%" onClick={this.handleMouseClick}>
<Logo transform="translate(24,24) scale(0.25)" />
<ZoomableCanvas
onClick={this.handleMouseClick}
zoomLimitsSelector={graphZoomLimitsSelector}
zoomStateSelector={graphZoomStateSelector}
disabled={selectedNodeId}>
<EdgeMarkerDefinition selectedNodeId={selectedNodeId} />
<ZoomWrapper
svg="canvas" disabled={selectedNodeId}
zoomLimitsSelector={graphZoomLimitsSelector}
zoomStateSelector={graphZoomStateSelector}>
<NodesChartElements />
</ZoomWrapper>
</svg>
<NodesChartElements />
</ZoomableCanvas>
</div>
);
}

View File

@@ -1,8 +1,7 @@
import React from 'react';
import { connect } from 'react-redux';
import Logo from './logo';
import ZoomWrapper from './zoom-wrapper';
import ZoomableCanvas from './zoomable-canvas';
import NodesResourcesLayer from './nodes-resources/node-resources-layer';
import { layersTopologyIdsSelector } from '../selectors/resource-view/layout';
import {
@@ -26,15 +25,12 @@ class NodesResources extends React.Component {
render() {
return (
<div className="nodes-resources">
<svg id="canvas" width="100%" height="100%">
<Logo transform="translate(24,24) scale(0.25)" />
<ZoomWrapper
svg="canvas" bounded forwardTransform fixVertical
zoomLimitsSelector={resourcesZoomLimitsSelector}
zoomStateSelector={resourcesZoomStateSelector}>
{transform => this.renderLayers(transform)}
</ZoomWrapper>
</svg>
<ZoomableCanvas
bounded forwardTransform fixVertical
zoomLimitsSelector={resourcesZoomLimitsSelector}
zoomStateSelector={resourcesZoomStateSelector}>
{transform => this.renderLayers(transform)}
</ZoomableCanvas>
</div>
);
}

View File

@@ -0,0 +1,65 @@
import React from 'react';
import Slider from 'rc-slider';
import { scaleLog } from 'd3-scale';
const SLIDER_STEP = 0.001;
const CLICK_STEP = 0.05;
// Returns a log-scale that maps zoom factors to slider values.
const getSliderScale = ({ minScale, maxScale }) => (
scaleLog()
// Zoom limits may vary between different views.
.domain([minScale, maxScale])
// Taking the unit range for the slider ensures consistency
// of the zoom button steps across different zoom domains.
.range([0, 1])
// This makes sure the input values are always clamped into the valid domain/range.
.clamp(true)
);
export default class ZoomControl extends React.Component {
constructor(props, context) {
super(props, context);
this.handleChange = this.handleChange.bind(this);
this.handleZoomOut = this.handleZoomOut.bind(this);
this.handleZoomIn = this.handleZoomIn.bind(this);
this.getSliderValue = this.getSliderValue.bind(this);
this.toZoomScale = this.toZoomScale.bind(this);
}
handleChange(sliderValue) {
this.props.zoomAction(this.toZoomScale(sliderValue));
}
handleZoomOut() {
this.props.zoomAction(this.toZoomScale(this.getSliderValue() - CLICK_STEP));
}
handleZoomIn() {
this.props.zoomAction(this.toZoomScale(this.getSliderValue() + CLICK_STEP));
}
getSliderValue() {
const toSliderValue = getSliderScale(this.props);
return toSliderValue(this.props.scale);
}
toZoomScale(sliderValue) {
const toSliderValue = getSliderScale(this.props);
return toSliderValue.invert(sliderValue);
}
render() {
const value = this.getSliderValue();
return (
<div className="zoom-control">
<a className="zoom-in" onClick={this.handleZoomIn}><span className="fa fa-plus" /></a>
<Slider value={value} max={1} step={SLIDER_STEP} vertical onChange={this.handleChange} />
<a className="zoom-out" onClick={this.handleZoomOut}><span className="fa fa-minus" /></a>
</div>
);
}
}

View File

@@ -6,6 +6,8 @@ import { fromJS } from 'immutable';
import { event as d3Event, select } from 'd3-selection';
import { zoom, zoomIdentity } from 'd3-zoom';
import Logo from '../components/logo';
import ZoomControl from '../components/zoom-control';
import { cacheZoomState } from '../actions/app-actions';
import { transformToString } from '../utils/transform-utils';
import { activeTopologyZoomCacheKeyPathSelector } from '../selectors/zooming';
@@ -18,7 +20,7 @@ import {
import { ZOOM_CACHE_DEBOUNCE_INTERVAL } from '../constants/timer';
class ZoomWrapper extends React.Component {
class ZoomableCanvas extends React.Component {
constructor(props, context) {
super(props, context);
@@ -36,13 +38,15 @@ class ZoomWrapper extends React.Component {
};
this.debouncedCacheZoom = debounce(this.cacheZoom.bind(this), ZOOM_CACHE_DEBOUNCE_INTERVAL);
this.handleZoomControlAction = this.handleZoomControlAction.bind(this);
this.canChangeZoom = this.canChangeZoom.bind(this);
this.zoomed = this.zoomed.bind(this);
}
componentDidMount() {
this.zoomRestored = false;
this.zoom = zoom().on('zoom', this.zoomed);
this.svg = select(`svg#${this.props.svg}`);
this.svg = select('svg#canvas');
this.setZoomTriggers(!this.props.disabled);
this.updateZoomLimits(this.props);
@@ -77,6 +81,18 @@ class ZoomWrapper extends React.Component {
}
}
handleZoomControlAction(scale) {
// Update the canvas scale (not touching the translation).
this.svg.call(this.zoom.scaleTo, scale);
// Update the scale state and propagate to the global cache.
this.setState(this.cachableState({
scaleX: scale,
scaleY: scale,
}));
this.debouncedCacheZoom();
}
render() {
// `forwardTransform` says whether the zoom transform is forwarded to the child
// component. The advantage of that is more control rendering control in the
@@ -86,8 +102,19 @@ class ZoomWrapper extends React.Component {
const transform = forwardTransform ? '' : transformToString(this.state);
return (
<g className="cachable-zoom-wrapper" transform={transform}>
{forwardTransform ? children(this.state) : children}
<g className="zoomable-canvas">
<svg id="canvas" width="100%" height="100%" onClick={this.props.onClick}>
<Logo transform="translate(24,24) scale(0.25)" />
<g className="zoom-content" transform={transform}>
{forwardTransform ? children(this.state) : children}
</g>
</svg>
{this.canChangeZoom() && <ZoomControl
zoomAction={this.handleZoomControlAction}
minScale={this.state.minScale}
maxScale={this.state.maxScale}
scale={this.state.scaleX}
/>}
</g>
);
}
@@ -157,8 +184,14 @@ class ZoomWrapper extends React.Component {
}
}
canChangeZoom() {
const { disabled, layoutZoomLimits } = this.props;
const canvasHasContent = !layoutZoomLimits.isEmpty();
return !disabled && canvasHasContent;
}
zoomed() {
if (!this.props.disabled) {
if (this.canChangeZoom()) {
const updatedState = this.cachableState({
scaleX: d3Event.transform.k,
scaleY: d3Event.transform.k,
@@ -189,4 +222,4 @@ function mapStateToProps(state, props) {
export default connect(
mapStateToProps,
{ cacheZoomState }
)(ZoomWrapper);
)(ZoomableCanvas);

View File

@@ -40,7 +40,7 @@ export const EDGE_WAYPOINTS_CAP = 10;
export const CANVAS_MARGINS = {
[GRAPH_VIEW_MODE]: { top: 160, left: 40, right: 40, bottom: 150 },
[TABLE_VIEW_MODE]: { top: 160, left: 40, right: 40, bottom: 30 },
[RESOURCE_VIEW_MODE]: { top: 160, left: 210, right: 40, bottom: 50 },
[RESOURCE_VIEW_MODE]: { top: 140, left: 210, right: 40, bottom: 150 },
};
// Node details table constants

View File

@@ -1,5 +1,6 @@
@import '~xterm/dist/xterm.css';
@import '~font-awesome/scss/font-awesome.scss';
@import '~rc-slider/dist/rc-slider.css';
@font-face {
font-family: "Roboto";
@@ -46,6 +47,16 @@
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.19), 0 6px 10px rgba(0, 0, 0, 0.23);
}
.overlay-wrapper {
background-color: fade-out($background-average-color, 0.1);
border-radius: 4px;
color: $text-tertiary-color;
display: flex;
font-size: 0.7rem;
padding: 5px;
position: absolute;
}
.btn-opacity {
@extend .palable;
opacity: $btn-opacity-default;
@@ -127,18 +138,13 @@
}
.footer {
padding: 5px;
position: absolute;
@extend .overlay-wrapper;
bottom: 11px;
right: 43px;
color: $text-tertiary-color;
background-color: fade-out($background-average-color, .1);
font-size: 0.7rem;
display: flex;
a {
color: $text-secondary-color;
@extend .btn-opacity;
color: $text-secondary-color;
cursor: pointer;
}
@@ -1748,6 +1754,36 @@
}
}
//
// Zoom control
//
.zoom-control {
@extend .overlay-wrapper;
align-items: center;
flex-direction: column;
padding: 10px 10px 5px;
bottom: 50px;
right: 40px;
.zoom-in, .zoom-out {
@extend .btn-opacity;
color: $text-secondary-color;
cursor: pointer;
font-size: 150%;
}
.rc-slider {
margin: 10px 0;
height: 60px;
.rc-slider-step { cursor: pointer; }
.rc-slider-track { background-color: $text-tertiary-color; }
.rc-slider-rail { background-color: $border-light-color; }
.rc-slider-handle { border-color: $text-tertiary-color; }
}
}
//
// Debug panel!
//

View File

@@ -29,6 +29,7 @@
"moment": "2.18.1",
"page": "1.7.1",
"prop-types": "^15.5.8",
"rc-slider": "^7.0.2",
"react": "15.5.4",
"react-addons-perf": "15.4.2",
"react-dom": "15.5.4",

View File

@@ -127,6 +127,7 @@ module.exports = {
includePaths: [
path.resolve(__dirname, './node_modules/xterm'),
path.resolve(__dirname, './node_modules/font-awesome'),
path.resolve(__dirname, './node_modules/rc-slider'),
]
}
}],

View File

@@ -135,7 +135,8 @@ module.exports = {
minimize: true,
includePaths: [
path.resolve(__dirname, './node_modules/xterm'),
path.resolve(__dirname, './node_modules/font-awesome')
path.resolve(__dirname, './node_modules/font-awesome'),
path.resolve(__dirname, './node_modules/rc-slider'),
]
}
}]

View File

@@ -54,6 +54,12 @@ acorn@^5.0.0:
version "5.0.3"
resolved "https://registry.yarnpkg.com/acorn/-/acorn-5.0.3.tgz#c460df08491463f028ccb82eab3730bf01087b3d"
add-dom-event-listener@1.x:
version "1.0.2"
resolved "https://registry.yarnpkg.com/add-dom-event-listener/-/add-dom-event-listener-1.0.2.tgz#8faed2c41008721cf111da1d30d995b85be42bed"
dependencies:
object-assign "4.x"
ajv-keywords@^1.0.0, ajv-keywords@^1.1.1:
version "1.5.1"
resolved "https://registry.yarnpkg.com/ajv-keywords/-/ajv-keywords-1.5.1.tgz#314dd0a4b3368fad3dfcdc54ede6171b886daf3c"
@@ -858,7 +864,7 @@ babel-register@^6.24.1:
mkdirp "^0.5.1"
source-map-support "^0.4.2"
babel-runtime@^6.0.0, babel-runtime@^6.18.0, babel-runtime@^6.2.0, babel-runtime@^6.22.0, babel-runtime@^6.6.1:
babel-runtime@6.x, babel-runtime@^6.0.0, babel-runtime@^6.18.0, babel-runtime@^6.2.0, babel-runtime@^6.22.0, babel-runtime@^6.6.1:
version "6.23.0"
resolved "https://registry.yarnpkg.com/babel-runtime/-/babel-runtime-6.23.0.tgz#0a9489f144de70efb3ce4300accdb329e2fc543b"
dependencies:
@@ -1370,6 +1376,16 @@ commondir@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/commondir/-/commondir-1.0.1.tgz#ddd800da0c66127393cca5950ea968a3aaf1253b"
component-classes@^1.2.5:
version "1.2.6"
resolved "https://registry.yarnpkg.com/component-classes/-/component-classes-1.2.6.tgz#c642394c3618a4d8b0b8919efccbbd930e5cd691"
dependencies:
component-indexof "0.0.3"
component-indexof@0.0.3:
version "0.0.3"
resolved "https://registry.yarnpkg.com/component-indexof/-/component-indexof-0.0.3.tgz#11d091312239eb8f32c8f25ae9cb002ffe8d3c24"
compress-commons@~0.2.0:
version "0.2.9"
resolved "https://registry.yarnpkg.com/compress-commons/-/compress-commons-0.2.9.tgz#422d927430c01abd06cd455b6dfc04cb4cf8003c"
@@ -1486,7 +1502,7 @@ create-hmac@^1.1.0, create-hmac@^1.1.2:
create-hash "^1.1.0"
inherits "^2.0.1"
create-react-class@^15.5.1:
create-react-class@^15.5.1, create-react-class@^15.5.2:
version "15.5.2"
resolved "https://registry.yarnpkg.com/create-react-class/-/create-react-class-15.5.2.tgz#6a8758348df660b88326a0e764d569f274aad681"
dependencies:
@@ -1521,6 +1537,12 @@ crypto-browserify@^3.11.0:
public-encrypt "^4.0.0"
randombytes "^2.0.0"
css-animation@^1.3.0:
version "1.3.2"
resolved "https://registry.yarnpkg.com/css-animation/-/css-animation-1.3.2.tgz#df515820ef5903733ad2db0999403b3037b8b880"
dependencies:
component-classes "^1.2.5"
css-color-names@0.0.4:
version "0.0.4"
resolved "https://registry.yarnpkg.com/css-color-names/-/css-color-names-0.0.4.tgz#808adc2e79cf84738069b646cb20ec27beb629e0"
@@ -1893,6 +1915,10 @@ doctrine@^2.0.0:
esutils "^2.0.2"
isarray "^1.0.0"
dom-align@1.x:
version "1.5.3"
resolved "https://registry.yarnpkg.com/dom-align/-/dom-align-1.5.3.tgz#b906b616822a5e599f579ec8505e367c51da7588"
dom-converter@~0.1:
version "0.1.4"
resolved "https://registry.yarnpkg.com/dom-converter/-/dom-converter-0.1.4.tgz#a45ef5727b890c9bffe6d7c876e7b19cb0e17f3b"
@@ -3806,6 +3832,22 @@ lodash.flow@^3.3.0:
version "3.5.0"
resolved "https://registry.yarnpkg.com/lodash.flow/-/lodash.flow-3.5.0.tgz#87bf40292b8cf83e4e8ce1a3ae4209e20071675a"
lodash.isarguments@^3.0.0:
version "3.1.0"
resolved "https://registry.yarnpkg.com/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz#2f573d85c6a24289ff00663b491c1d338ff3458a"
lodash.isarray@^3.0.0:
version "3.0.4"
resolved "https://registry.yarnpkg.com/lodash.isarray/-/lodash.isarray-3.0.4.tgz#79e4eb88c36a8122af86f844aa9bcd851b5fbb55"
lodash.keys@^3.1.2:
version "3.1.2"
resolved "https://registry.yarnpkg.com/lodash.keys/-/lodash.keys-3.1.2.tgz#4dbc0472b156be50a0b286855d1bd0b0c656098a"
dependencies:
lodash._getnative "^3.0.0"
lodash.isarguments "^3.0.0"
lodash.isarray "^3.0.0"
lodash.memoize@^4.1.0:
version "4.1.2"
resolved "https://registry.yarnpkg.com/lodash.memoize/-/lodash.memoize-4.1.2.tgz#bcc6c49a42a2840ed997f323eada5ecd182e0bfe"
@@ -4295,7 +4337,7 @@ oauth-sign@~0.8.1:
version "0.8.2"
resolved "https://registry.yarnpkg.com/oauth-sign/-/oauth-sign-0.8.2.tgz#46a6ab7f0aead8deae9ec0565780b7d4efeb9d43"
object-assign@^4.0.1, object-assign@^4.1.0, object-assign@^4.1.1:
object-assign@4.x, object-assign@^4.0.1, object-assign@^4.1.0, object-assign@^4.1.1:
version "4.1.1"
resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863"
@@ -4885,7 +4927,7 @@ promise@^7.1.1:
dependencies:
asap "~2.0.3"
prop-types@^15.0.0, prop-types@^15.5.7, prop-types@^15.5.8, prop-types@~15.5.7:
prop-types@^15.0.0, prop-types@^15.5.4, prop-types@^15.5.6, prop-types@^15.5.7, prop-types@^15.5.8, prop-types@~15.5.7:
version "15.5.8"
resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.5.8.tgz#6b7b2e141083be38c8595aa51fc55775c7199394"
dependencies:
@@ -4976,6 +5018,57 @@ range-parser@^1.0.3, range-parser@~1.2.0:
version "1.2.0"
resolved "https://registry.yarnpkg.com/range-parser/-/range-parser-1.2.0.tgz#f49be6b487894ddc40dcc94a322f611092e00d5e"
rc-align@2.x:
version "2.3.4"
resolved "https://registry.yarnpkg.com/rc-align/-/rc-align-2.3.4.tgz#d83bdab7560f0142e72a3de1d495dab6ba225249"
dependencies:
dom-align "1.x"
prop-types "^15.5.8"
rc-util "4.x"
rc-animate@2.x:
version "2.3.6"
resolved "https://registry.yarnpkg.com/rc-animate/-/rc-animate-2.3.6.tgz#4177a2822c67adf4fd5b908ceba7cee8d13c1fac"
dependencies:
css-animation "^1.3.0"
prop-types "^15.5.6"
rc-slider@^7.0.2:
version "7.0.3"
resolved "https://registry.yarnpkg.com/rc-slider/-/rc-slider-7.0.3.tgz#22eb144e54bd5643308bfec2cb396444c747db79"
dependencies:
babel-runtime "6.x"
classnames "^2.2.5"
prop-types "^15.5.4"
rc-tooltip "^3.4.2"
rc-util "^4.0.0"
warning "^3.0.0"
rc-tooltip@^3.4.2:
version "3.4.3"
resolved "https://registry.yarnpkg.com/rc-tooltip/-/rc-tooltip-3.4.3.tgz#6ae6ef0eccea39ca2add125bccdb34802c612eec"
dependencies:
prop-types "^15.5.8"
rc-trigger "1.x"
rc-trigger@1.x:
version "1.10.3"
resolved "https://registry.yarnpkg.com/rc-trigger/-/rc-trigger-1.10.3.tgz#6bf367ac69705662224003d8b0277b9dfbf5cef4"
dependencies:
babel-runtime "6.x"
create-react-class "^15.5.2"
prop-types "^15.5.8"
rc-align "2.x"
rc-animate "2.x"
rc-util "4.x"
rc-util@4.x, rc-util@^4.0.0:
version "4.0.2"
resolved "https://registry.yarnpkg.com/rc-util/-/rc-util-4.0.2.tgz#5804f8b141a13c8c14d7c265a7d21d298195af46"
dependencies:
add-dom-event-listener "1.x"
shallowequal "^0.2.2"
rc@^1.1.7:
version "1.1.7"
resolved "https://registry.yarnpkg.com/rc/-/rc-1.1.7.tgz#c5ea564bb07aff9fd3a5b32e906c1d3a65940fea"
@@ -5532,6 +5625,12 @@ shallow-clone@^0.1.2:
lazy-cache "^0.2.3"
mixin-object "^2.0.1"
shallowequal@^0.2.2:
version "0.2.2"
resolved "https://registry.yarnpkg.com/shallowequal/-/shallowequal-0.2.2.tgz#1e32fd5bcab6ad688a4812cb0cc04efc75c7014e"
dependencies:
lodash.keys "^3.1.2"
shelljs@^0.7.5:
version "0.7.7"
resolved "https://registry.yarnpkg.com/shelljs/-/shelljs-0.7.7.tgz#b2f5c77ef97148f4b4f6e22682e10bba8667cff1"
@@ -6064,6 +6163,12 @@ walker@~1.0.5:
dependencies:
makeerror "1.0.x"
warning@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/warning/-/warning-3.0.0.tgz#32e5377cb572de4ab04753bdf8821c01ed605b7c"
dependencies:
loose-envify "^1.0.0"
watch@~0.10.0:
version "0.10.0"
resolved "https://registry.yarnpkg.com/watch/-/watch-0.10.0.tgz#77798b2da0f9910d595f1ace5b0c2258521f21dc"