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 (
{this.props.children(this.state)} {this.canChangeZoom() && ( )}
); } // 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);