123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290 |
- import React from 'react';
- import classNames from 'classnames';
- import { connect } from 'react-redux';
- import { clamp, debounce, pick } from 'lodash';
- import { fromJS } from 'immutable';
- import stableStringify from 'json-stable-stringify';
- import { drag } from 'd3-drag';
- import { event as d3Event, select } from 'd3-selection';
- import { zoomFactor } from 'weaveworks-ui-components/lib/utils/zooming';
- import Logo from './logo';
- import ZoomControl from './zoom-control';
- import { cacheZoomState } from '../actions/app-actions';
- import { applyTransform, inverseTransform } from '../utils/transform-utils';
- import { activeTopologyZoomCacheKeyPathSelector } from '../selectors/zooming';
- import {
- canvasMarginsSelector,
- canvasWidthSelector,
- canvasHeightSelector,
- } from '../selectors/canvas';
- import { ZOOM_CACHE_DEBOUNCE_INTERVAL } from '../constants/timer';
- import { CONTENT_INCLUDED, CONTENT_COVERING } from '../constants/naming';
- class ZoomableCanvas extends React.Component {
- constructor(props, context) {
- super(props, context);
- this.state = {
- contentMaxX: 0,
- contentMaxY: 0,
- contentMinX: 0,
- contentMinY: 0,
- isPanning: false,
- maxScale: 1,
- minScale: 1,
- scaleX: 1, //默认是1
- scaleY: 1,
- translateX: 0,
- translateY: 0,
- };
- this.debouncedCacheZoom = debounce(this.cacheZoom.bind(this), ZOOM_CACHE_DEBOUNCE_INTERVAL);
- this.handleZoomControlAction = this.handleZoomControlAction.bind(this);
- this.canChangeZoom = this.canChangeZoom.bind(this);
- this.handleZoom = this.handleZoom.bind(this);
- this.handlePanStart = this.handlePanStart.bind(this);
- this.handlePanEnd = this.handlePanEnd.bind(this);
- this.handlePan = this.handlePan.bind(this);
- }
- componentDidMount() {
- this.svg = select('.zoomable-canvas svg');
- this.drag = drag()
- .on('start', this.handlePanStart)
- .on('end', this.handlePanEnd)
- .on('drag', this.handlePan);
- this.svg.call(this.drag);
- this.zoomRestored = false;
- this.updateZoomLimits(this.props);
- this.restoreZoomState(this.props);
- document
- .getElementById('canvas')
- .addEventListener('wheel', this.handleZoom, { passive: false });
- }
- componentWillUnmount() {
- this.debouncedCacheZoom.cancel();
- document
- .getElementById('canvas')
- .removeEventListener('wheel', this.handleZoom, { passive: false });
- }
- componentWillReceiveProps(nextProps) {
- const layoutChanged = nextProps.layoutId !== this.props.layoutId;
- // If the layout has changed (either active topology or its options) or
- // relayouting has been requested, stop pending zoom caching event and
- // ask for the new zoom settings to be restored again from the cache.
- if (layoutChanged || nextProps.forceRelayout) {
- this.debouncedCacheZoom.cancel();
- this.zoomRestored = false;
- }
- this.updateZoomLimits(nextProps);
- if (!this.zoomRestored) {
- this.restoreZoomState(nextProps);
- }
- }
- handleZoomControlAction(scale) {
- // Get the center of the SVG and zoom around it.
- const {
- top, bottom, left, right
- } = this.svg.node().getBoundingClientRect();
- const centerOfCanvas = {
- x: (left + right) / 2,
- y: (top + bottom) / 2,
- };
- // Zoom factor diff is obtained by dividing the new zoom scale with the old one.
- this.zoomAtPositionByFactor(centerOfCanvas, scale / this.state.scaleX);
- }
- render() {
- const className = classNames({ panning: this.state.isPanning });
- return (
- <div className="zoomable-canvas">
- <svg id="canvas" className={className} onClick={this.props.onClick}>
- <Logo transform="translate(24,24) scale(0.25)" />
- <g className="zoom-content">
- {this.props.children(this.state)}
- </g>
- </svg>
- {this.canChangeZoom() && (
- <ZoomControl
- zoomAction={this.handleZoomControlAction}
- minScale={this.state.minScale}
- maxScale={this.state.maxScale}
- scale={this.state.scaleX}
- />
- )}
- </div>
- );
- }
- // Decides which part of the zoom state is cachable depending
- // on the horizontal/vertical degrees of freedom.
- cachableState(state = this.state) {
- const cachableFields = []
- .concat(this.props.fixHorizontal ? [] : ['scaleX', 'translateX'])
- .concat(this.props.fixVertical ? [] : ['scaleY', 'translateY']);
- return pick(state, cachableFields);
- }
- cacheZoom() {
- this.props.cacheZoomState(fromJS(this.cachableState()));
- }
- updateZoomLimits(props) {
- this.setState(props.layoutLimits.toJS());
- }
- // Restore the zooming settings
- restoreZoomState(props) {
- if (!props.layoutZoomState.isEmpty()) {
- const zoomState = props.layoutZoomState.toJS();
- // Update the state variables.
- this.setState(zoomState);
- this.zoomRestored = true;
- }
- }
- canChangeZoom() {
- const { disabled, layoutLimits } = this.props;
- const canvasHasContent = !layoutLimits.isEmpty();
- return !disabled && canvasHasContent;
- }
- handlePanStart() {
- this.setState({ isPanning: true });
- }
- handlePanEnd() {
- this.setState({ isPanning: false });
- }
- handlePan() {
- let { state } = this;
- // Apply the translation respecting the boundaries.
- state = this.clampedTranslation({
- ...state,
- translateX: this.state.translateX + d3Event.dx,
- translateY: this.state.translateY + d3Event.dy,
- });
- this.updateState(state);
- }
- handleZoom(ev) {
- if (this.canChangeZoom()) {
- // Get the exact mouse cursor position in the SVG and zoom around it.
- const { top, left } = this.svg.node().getBoundingClientRect();
- const mousePosition = {
- x: ev.clientX - left,
- y: ev.clientY - top,
- };
- this.zoomAtPositionByFactor(mousePosition, zoomFactor(ev));
- }
- ev.preventDefault();
- }
- clampedTranslation(state) {
- const {
- width, height, canvasMargins, boundContent, layoutLimits
- } = this.props;
- const {
- contentMinX, contentMaxX, contentMinY, contentMaxY
- } = layoutLimits.toJS();
- if (boundContent) {
- // If the content is required to be bounded in any way, the translation will
- // be adjusted so that certain constraints between the viewport and displayed
- // content bounding box are met.
- const viewportMin = { x: canvasMargins.left, y: canvasMargins.top };
- const viewportMax = { x: canvasMargins.left + width, y: canvasMargins.top + height };
- const contentMin = applyTransform(state, { x: contentMinX, y: contentMinY });
- const contentMax = applyTransform(state, { x: contentMaxX, y: contentMaxY });
- switch (boundContent) {
- case CONTENT_COVERING:
- // These lines will adjust the translation by 'minimal effort' in
- // such a way that the content always FULLY covers the viewport,
- // i.e. that the viewport rectangle is always fully contained in
- // the content bounding box rectangle - the assumption made here
- // is that that can always be done.
- state.translateX += Math.max(0, viewportMax.x - contentMax.x);
- state.translateX -= Math.max(0, contentMin.x - viewportMin.x);
- state.translateY += Math.max(0, viewportMax.y - contentMax.y);
- state.translateY -= Math.max(0, contentMin.y - viewportMin.y);
- break;
- case CONTENT_INCLUDED:
- // These lines will adjust the translation by 'minimal effort' in
- // such a way that the content is always at least PARTLY contained
- // within the viewport, i.e. that the intersection between the
- // viewport and the content bounding box always exists.
- state.translateX -= Math.max(0, contentMin.x - viewportMax.x);
- state.translateX += Math.max(0, viewportMin.x - contentMax.x);
- state.translateY -= Math.max(0, contentMin.y - viewportMax.y);
- state.translateY += Math.max(0, viewportMin.y - contentMax.y);
- break;
- default:
- break;
- }
- }
- return state;
- }
- zoomAtPositionByFactor(position, factor) {
- // Update the scales by the given factor, respecting the zoom limits.
- const { minScale, maxScale } = this.state;
- const scaleX = clamp(this.state.scaleX * factor, minScale, maxScale);
- const scaleY = clamp(this.state.scaleY * factor, minScale, maxScale);
- let state = { ...this.state, scaleX, scaleY };
- // Get the position in the coordinates before the transition and use it
- // to adjust the translation part of the new transition (respecting the
- // translation limits). Adapted from:
- // https://github.com/d3/d3-zoom/blob/807f02c7a5fe496fbd08cc3417b62905a8ce95fa/src/zoom.js#L251
- const inversePosition = inverseTransform(this.state, position);
- state = this.clampedTranslation({
- ...state,
- translateX: position.x - (inversePosition.x * scaleX),
- translateY: position.y - (inversePosition.y * scaleY),
- });
- this.updateState(state);
- }
- updateState(state) {
- this.setState(this.cachableState(state));
- this.debouncedCacheZoom();
- }
- }
- function mapStateToProps(state, props) {
- return {
- canvasMargins: canvasMarginsSelector(state),
- forceRelayout: state.get('forceRelayout'),
- height: canvasHeightSelector(state),
- layoutId: stableStringify(activeTopologyZoomCacheKeyPathSelector(state)),
- layoutLimits: props.limitsSelector(state),
- layoutZoomState: props.zoomStateSelector(state),
- width: canvasWidthSelector(state),
- };
- }
- export default connect(
- mapStateToProps,
- { cacheZoomState }
- )(ZoomableCanvas);
|