Skip to content

Commit

Permalink
Canvas zoom control (#2513)
Browse files Browse the repository at this point in the history
* 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.
  • Loading branch information
fbarl authored May 15, 2017
1 parent 963a411 commit 5935a32
Show file tree
Hide file tree
Showing 10 changed files with 277 additions and 41 deletions.
20 changes: 9 additions & 11 deletions client/app/scripts/charts/nodes-chart.js
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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>
);
}
Expand Down
18 changes: 7 additions & 11 deletions client/app/scripts/components/nodes-resources.js
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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>
);
}
Expand Down
65 changes: 65 additions & 0 deletions client/app/scripts/components/zoom-control.js
Original file line number Diff line number Diff line change
@@ -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>
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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);

Expand All @@ -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);
Expand Down Expand Up @@ -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
Expand All @@ -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>
);
}
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -189,4 +222,4 @@ function mapStateToProps(state, props) {
export default connect(
mapStateToProps,
{ cacheZoomState }
)(ZoomWrapper);
)(ZoomableCanvas);
2 changes: 1 addition & 1 deletion client/app/scripts/constants/styles.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
50 changes: 43 additions & 7 deletions client/app/styles/_base.scss
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -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!
//
Expand Down
1 change: 1 addition & 0 deletions client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions client/webpack.local.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
]
}
}],
Expand Down
3 changes: 2 additions & 1 deletion client/webpack.production.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
]
}
}]
Expand Down
Loading

0 comments on commit 5935a32

Please sign in to comment.