@antv/x6
Version:
JavaScript diagramming library that uses SVG and HTML for rendering
605 lines (504 loc) • 16.7 kB
text/typescript
import { Dom, NumberExt } from '@antv/x6-common'
import { Point, Rectangle } from '@antv/x6-geometry'
import { Base } from './base'
import { Util } from '../util'
import { Cell } from '../model'
export class TransformManager extends Base {
protected viewportMatrix: DOMMatrix | null
protected viewportTransformString: string | null
protected get container() {
return this.graph.view.container
}
protected get viewport() {
return this.graph.view.viewport
}
protected get stage() {
return this.graph.view.stage
}
protected init() {
this.resize()
}
/**
* Returns the current transformation matrix of the graph.
*/
getMatrix() {
const transform = this.viewport.getAttribute('transform')
if (transform !== this.viewportTransformString) {
// `getCTM`: top-left relative to the SVG element
// `getScreenCTM`: top-left relative to the document
this.viewportMatrix = this.viewport.getCTM()
this.viewportTransformString = transform
}
// Clone the cached current transformation matrix.
// If no matrix previously stored the identity matrix is returned.
return Dom.createSVGMatrix(this.viewportMatrix)
}
/**
* Sets new transformation with the given `matrix`
*/
setMatrix(matrix: DOMMatrix | Dom.MatrixLike | null) {
const ctm = Dom.createSVGMatrix(matrix)
const transform = Dom.matrixToTransformString(ctm)
this.viewport.setAttribute('transform', transform)
this.viewportMatrix = ctm
this.viewportTransformString = transform
}
resize(width?: number, height?: number) {
let w = width === undefined ? this.options.width : width
let h = height === undefined ? this.options.height : height
this.options.width = w
this.options.height = h
if (typeof w === 'number') {
w = Math.round(w)
}
if (typeof h === 'number') {
h = Math.round(h)
}
this.container.style.width = w == null ? '' : `${w}px`
this.container.style.height = h == null ? '' : `${h}px`
const size = this.getComputedSize()
this.graph.trigger('resize', { ...size })
return this
}
getComputedSize() {
let w = this.options.width
let h = this.options.height
if (!NumberExt.isNumber(w)) {
w = this.container.clientWidth
}
if (!NumberExt.isNumber(h)) {
h = this.container.clientHeight
}
return { width: w, height: h }
}
getScale() {
return Dom.matrixToScale(this.getMatrix())
}
scale(sx: number, sy: number = sx, ox = 0, oy = 0) {
sx = this.clampScale(sx) // eslint-disable-line
sy = this.clampScale(sy) // eslint-disable-line
if (ox || oy) {
const ts = this.getTranslation()
const tx = ts.tx - ox * (sx - 1)
const ty = ts.ty - oy * (sy - 1)
if (tx !== ts.tx || ty !== ts.ty) {
this.translate(tx, ty)
}
}
const matrix = this.getMatrix()
matrix.a = sx
matrix.d = sy
this.setMatrix(matrix)
this.graph.trigger('scale', { sx, sy, ox, oy })
return this
}
clampScale(scale: number) {
const range = this.graph.options.scaling
return NumberExt.clamp(scale, range.min || 0.01, range.max || 16)
}
getZoom() {
return this.getScale().sx
}
zoom(factor: number, options?: TransformManager.ZoomOptions) {
options = options || {} // eslint-disable-line
let sx = factor
let sy = factor
const scale = this.getScale()
const clientSize = this.getComputedSize()
let cx = clientSize.width / 2
let cy = clientSize.height / 2
if (!options.absolute) {
sx += scale.sx
sy += scale.sy
}
if (options.scaleGrid) {
sx = Math.round(sx / options.scaleGrid) * options.scaleGrid
sy = Math.round(sy / options.scaleGrid) * options.scaleGrid
}
if (options.maxScale) {
sx = Math.min(options.maxScale, sx)
sy = Math.min(options.maxScale, sy)
}
if (options.minScale) {
sx = Math.max(options.minScale, sx)
sy = Math.max(options.minScale, sy)
}
if (options.center) {
cx = options.center.x
cy = options.center.y
}
sx = this.clampScale(sx)
sy = this.clampScale(sy)
if (cx || cy) {
const ts = this.getTranslation()
const tx = cx - (cx - ts.tx) * (sx / scale.sx)
const ty = cy - (cy - ts.ty) * (sy / scale.sy)
if (tx !== ts.tx || ty !== ts.ty) {
this.translate(tx, ty)
}
}
this.scale(sx, sy)
return this
}
getRotation() {
return Dom.matrixToRotation(this.getMatrix())
}
rotate(angle: number, cx?: number, cy?: number) {
if (cx == null || cy == null) {
const bbox = Util.getBBox(this.stage)
cx = bbox.width / 2 // eslint-disable-line
cy = bbox.height / 2 // eslint-disable-line
}
const ctm = this.getMatrix()
.translate(cx, cy)
.rotate(angle)
.translate(-cx, -cy)
this.setMatrix(ctm)
return this
}
getTranslation() {
return Dom.matrixToTranslation(this.getMatrix())
}
translate(tx: number, ty: number) {
const matrix = this.getMatrix()
matrix.e = tx || 0
matrix.f = ty || 0
this.setMatrix(matrix)
const ts = this.getTranslation()
this.options.x = ts.tx
this.options.y = ts.ty
this.graph.trigger('translate', { ...ts })
return this
}
setOrigin(ox?: number, oy?: number) {
return this.translate(ox || 0, oy || 0)
}
fitToContent(
gridWidth?: number | TransformManager.FitToContentFullOptions,
gridHeight?: number,
padding?: NumberExt.SideOptions,
options?: TransformManager.FitToContentOptions,
) {
if (typeof gridWidth === 'object') {
const opts = gridWidth
gridWidth = opts.gridWidth || 1 // eslint-disable-line
gridHeight = opts.gridHeight || 1 // eslint-disable-line
padding = opts.padding || 0 // eslint-disable-line
options = opts // eslint-disable-line
} else {
gridWidth = gridWidth || 1 // eslint-disable-line
gridHeight = gridHeight || 1 // eslint-disable-line
padding = padding || 0 // eslint-disable-line
if (options == null) {
options = {} // eslint-disable-line
}
}
const paddings = NumberExt.normalizeSides(padding)
const border = options.border || 0
const contentArea = options.contentArea
? Rectangle.create(options.contentArea)
: this.getContentArea(options)
if (border > 0) {
contentArea.inflate(border)
}
const scale = this.getScale()
const translate = this.getTranslation()
const sx = scale.sx
const sy = scale.sy
contentArea.x *= sx
contentArea.y *= sy
contentArea.width *= sx
contentArea.height *= sy
let width =
Math.max(Math.ceil((contentArea.width + contentArea.x) / gridWidth), 1) *
gridWidth
let height =
Math.max(
Math.ceil((contentArea.height + contentArea.y) / gridHeight),
1,
) * gridHeight
let tx = 0
let ty = 0
if (
(options.allowNewOrigin === 'negative' && contentArea.x < 0) ||
(options.allowNewOrigin === 'positive' && contentArea.x >= 0) ||
options.allowNewOrigin === 'any'
) {
tx = Math.ceil(-contentArea.x / gridWidth) * gridWidth
tx += paddings.left
width += tx
}
if (
(options.allowNewOrigin === 'negative' && contentArea.y < 0) ||
(options.allowNewOrigin === 'positive' && contentArea.y >= 0) ||
options.allowNewOrigin === 'any'
) {
ty = Math.ceil(-contentArea.y / gridHeight) * gridHeight
ty += paddings.top
height += ty
}
width += paddings.right
height += paddings.bottom
// Make sure the resulting width and height are greater than minimum.
width = Math.max(width, options.minWidth || 0)
height = Math.max(height, options.minHeight || 0)
// Make sure the resulting width and height are lesser than maximum.
width = Math.min(width, options.maxWidth || Number.MAX_SAFE_INTEGER)
height = Math.min(height, options.maxHeight || Number.MAX_SAFE_INTEGER)
const size = this.getComputedSize()
const sizeChanged = width !== size.width || height !== size.height
const originChanged = tx !== translate.tx || ty !== translate.ty
// Change the dimensions only if there is a size discrepency or an origin change
if (originChanged) {
this.translate(tx, ty)
}
if (sizeChanged) {
this.resize(width, height)
}
return new Rectangle(-tx / sx, -ty / sy, width / sx, height / sy)
}
scaleContentToFit(options: TransformManager.ScaleContentToFitOptions = {}) {
this.scaleContentToFitImpl(options)
}
scaleContentToFitImpl(
options: TransformManager.ScaleContentToFitOptions = {},
translate = true,
) {
let contentBBox
let contentLocalOrigin
if (options.contentArea) {
const contentArea = options.contentArea
contentBBox = this.graph.localToGraph(contentArea)
contentLocalOrigin = Point.create(contentArea)
} else {
contentBBox = this.getContentBBox(options)
contentLocalOrigin = this.graph.graphToLocal(contentBBox)
}
if (!contentBBox.width || !contentBBox.height) {
return
}
const padding = NumberExt.normalizeSides(options.padding)
const minScale = options.minScale || 0
const maxScale = options.maxScale || Number.MAX_SAFE_INTEGER
const minScaleX = options.minScaleX || minScale
const maxScaleX = options.maxScaleX || maxScale
const minScaleY = options.minScaleY || minScale
const maxScaleY = options.maxScaleY || maxScale
let fittingBox
if (options.viewportArea) {
fittingBox = options.viewportArea
} else {
const computedSize = this.getComputedSize()
const currentTranslate = this.getTranslation()
fittingBox = {
x: currentTranslate.tx,
y: currentTranslate.ty,
width: computedSize.width,
height: computedSize.height,
}
}
fittingBox = Rectangle.create(fittingBox).moveAndExpand({
x: padding.left,
y: padding.top,
width: -padding.left - padding.right,
height: -padding.top - padding.bottom,
})
const currentScale = this.getScale()
let newSX = (fittingBox.width / contentBBox.width) * currentScale.sx
let newSY = (fittingBox.height / contentBBox.height) * currentScale.sy
if (options.preserveAspectRatio !== false) {
newSX = newSY = Math.min(newSX, newSY)
}
// snap scale to a grid
const gridSize = options.scaleGrid
if (gridSize) {
newSX = gridSize * Math.floor(newSX / gridSize)
newSY = gridSize * Math.floor(newSY / gridSize)
}
// scale min/max boundaries
newSX = NumberExt.clamp(newSX, minScaleX, maxScaleX)
newSY = NumberExt.clamp(newSY, minScaleY, maxScaleY)
this.scale(newSX, newSY)
if (translate) {
const origin = this.options
const newOX = fittingBox.x - contentLocalOrigin.x * newSX - origin.x
const newOY = fittingBox.y - contentLocalOrigin.y * newSY - origin.y
this.translate(newOX, newOY)
}
}
getContentArea(options: TransformManager.GetContentAreaOptions = {}) {
// use geometry calc default
if (options.useCellGeometry !== false) {
return this.model.getAllCellsBBox() || new Rectangle()
}
return Util.getBBox(this.stage)
}
getContentBBox(options: TransformManager.GetContentAreaOptions = {}) {
return this.graph.localToGraph(this.getContentArea(options))
}
getGraphArea() {
const rect = Rectangle.fromSize(this.getComputedSize())
return this.graph.graphToLocal(rect)
}
zoomToRect(
rect: Rectangle.RectangleLike,
options: TransformManager.ScaleContentToFitOptions = {},
) {
const area = Rectangle.create(rect)
const graph = this.graph
options.contentArea = area
if (options.viewportArea == null) {
options.viewportArea = {
x: graph.options.x,
y: graph.options.y,
width: this.options.width,
height: this.options.height,
}
}
this.scaleContentToFitImpl(options, false)
const center = area.getCenter()
this.centerPoint(center.x, center.y)
return this
}
zoomToFit(
options: TransformManager.GetContentAreaOptions &
TransformManager.ScaleContentToFitOptions = {},
) {
return this.zoomToRect(this.getContentArea(options), options)
}
centerPoint(x?: number, y?: number) {
const clientSize = this.getComputedSize()
const scale = this.getScale()
const ts = this.getTranslation()
const cx = clientSize.width / 2
const cy = clientSize.height / 2
x = typeof x === 'number' ? x : cx // eslint-disable-line
y = typeof y === 'number' ? y : cy // eslint-disable-line
x = cx - x * scale.sx // eslint-disable-line
y = cy - y * scale.sy // eslint-disable-line
if (ts.tx !== x || ts.ty !== y) {
this.translate(x, y)
}
}
centerContent(options?: TransformManager.GetContentAreaOptions) {
const rect = this.graph.getContentArea(options)
const center = rect.getCenter()
this.centerPoint(center.x, center.y)
}
centerCell(cell: Cell) {
return this.positionCell(cell, 'center')
}
positionPoint(
point: Point.PointLike,
x: number | string,
y: number | string,
) {
const clientSize = this.getComputedSize()
// eslint-disable-next-line
x = NumberExt.normalizePercentage(x, Math.max(0, clientSize.width))
if (x < 0) {
x = clientSize.width + x // eslint-disable-line
}
// eslint-disable-next-line
y = NumberExt.normalizePercentage(y, Math.max(0, clientSize.height))
if (y < 0) {
y = clientSize.height + y // eslint-disable-line
}
const ts = this.getTranslation()
const scale = this.getScale()
const dx = x - point.x * scale.sx
const dy = y - point.y * scale.sy
if (ts.tx !== dx || ts.ty !== dy) {
this.translate(dx, dy)
}
}
positionRect(rect: Rectangle.RectangleLike, pos: TransformManager.Direction) {
const bbox = Rectangle.create(rect)
switch (pos) {
case 'center':
return this.positionPoint(bbox.getCenter(), '50%', '50%')
case 'top':
return this.positionPoint(bbox.getTopCenter(), '50%', 0)
case 'top-right':
return this.positionPoint(bbox.getTopRight(), '100%', 0)
case 'right':
return this.positionPoint(bbox.getRightMiddle(), '100%', '50%')
case 'bottom-right':
return this.positionPoint(bbox.getBottomRight(), '100%', '100%')
case 'bottom':
return this.positionPoint(bbox.getBottomCenter(), '50%', '100%')
case 'bottom-left':
return this.positionPoint(bbox.getBottomLeft(), 0, '100%')
case 'left':
return this.positionPoint(bbox.getLeftMiddle(), 0, '50%')
case 'top-left':
return this.positionPoint(bbox.getTopLeft(), 0, 0)
default:
return this
}
}
positionCell(cell: Cell, pos: TransformManager.Direction) {
const bbox = cell.getBBox()
return this.positionRect(bbox, pos)
}
positionContent(
pos: TransformManager.Direction,
options?: TransformManager.GetContentAreaOptions,
) {
const rect = this.graph.getContentArea(options)
return this.positionRect(rect, pos)
}
}
export namespace TransformManager {
export interface FitToContentOptions extends GetContentAreaOptions {
minWidth?: number
minHeight?: number
maxWidth?: number
maxHeight?: number
contentArea?: Rectangle | Rectangle.RectangleLike
border?: number
allowNewOrigin?: 'negative' | 'positive' | 'any'
}
export interface FitToContentFullOptions extends FitToContentOptions {
gridWidth?: number
gridHeight?: number
padding?: NumberExt.SideOptions
}
export interface ScaleContentToFitOptions extends GetContentAreaOptions {
padding?: NumberExt.SideOptions
minScale?: number
maxScale?: number
minScaleX?: number
minScaleY?: number
maxScaleX?: number
maxScaleY?: number
scaleGrid?: number
contentArea?: Rectangle.RectangleLike
viewportArea?: Rectangle.RectangleLike
preserveAspectRatio?: boolean
}
export interface GetContentAreaOptions {
useCellGeometry?: boolean
}
export interface ZoomOptions {
absolute?: boolean
minScale?: number
maxScale?: number
scaleGrid?: number
center?: Point.PointLike
}
export type Direction =
| 'center'
| 'top'
| 'top-right'
| 'top-left'
| 'right'
| 'bottom-right'
| 'bottom'
| 'bottom-left'
| 'left'
export interface CenterOptions {
padding?: NumberExt.SideOptions
}
export type PositionContentOptions = GetContentAreaOptions & CenterOptions
}