@antv/x6
Version:
JavaScript diagramming library that uses SVG and HTML for rendering.
1,329 lines (1,136 loc) • 37.3 kB
text/typescript
import { Platform, NumberExt, ObjectExt, Dom, FunctionExt } from '../../util'
import { Point, Rectangle } from '../../geometry'
import { Model } from '../../model/model'
import { Cell } from '../../model/cell'
import { View } from '../../view/view'
import { Graph } from '../../graph'
import { Renderer } from '../../graph/renderer'
import { GraphView } from '../../graph/view'
import { EventArgs } from '../../graph/events'
import { TransformManager } from '../../graph/transform'
import { BackgroundManager } from '../../graph/background'
export class Scroller extends View {
public readonly options: Scroller.Options
public readonly container: HTMLDivElement
public readonly content: HTMLDivElement
public readonly background: HTMLDivElement
public readonly $container: JQuery<HTMLElement>
public readonly backgroundManager: Scroller.Background
protected readonly $background: JQuery<HTMLElement>
protected readonly $content: JQuery<HTMLElement>
protected pageBreak: HTMLDivElement | null
public get graph() {
return this.options.graph
}
public get model() {
return this.graph.model
}
protected sx: number
protected sy: number
protected clientX: number
protected clientY: number
protected padding = { left: 0, top: 0, right: 0, bottom: 0 }
protected cachedScrollLeft: number | null
protected cachedScrollTop: number | null
protected cachedCenterPoint: Point.PointLike | null
protected cachedClientSize: { width: number; height: number } | null
protected delegatedHandlers: { [name: string]: (...args: any) => any }
constructor(options: Scroller.Options) {
super()
this.options = Util.getOptions(options)
const scale = this.graph.transform.getScale()
this.sx = scale.sx
this.sy = scale.sy
const width = this.options.width || this.graph.options.width
const height = this.options.height || this.graph.options.height
this.container = document.createElement('div')
this.$container = this.$(this.container)
.addClass(this.prefixClassName(Util.containerClass))
.css({ width, height })
if (this.options.pageVisible) {
this.$container.addClass(this.prefixClassName(Util.pagedClass))
}
if (this.options.className) {
this.$container.addClass(this.options.className)
}
const graphContainer = this.graph.container
if (graphContainer.parentNode) {
this.$container.insertBefore(graphContainer)
}
// copy style
const style = graphContainer.getAttribute('style')
if (style) {
const obj: { [name: string]: string } = {}
const styles = style.split(';')
styles.forEach((item) => {
const section = item.trim()
if (section) {
const pair = section.split(':')
if (pair.length) {
obj[pair[0].trim()] = pair[1] ? pair[1].trim() : ''
}
}
})
Object.keys(obj).forEach((key: any) => {
if (key === 'width' || key === 'height') {
return
}
graphContainer.style[key] = ''
this.container.style[key] = obj[key]
})
}
this.content = document.createElement('div')
this.$content = this.$(this.content)
.addClass(this.prefixClassName(Util.contentClass))
.css({
width: this.graph.options.width,
height: this.graph.options.height,
})
// custom background
this.background = document.createElement('div')
this.$background = this.$(this.background).addClass(
this.prefixClassName(Util.backgroundClass),
)
this.$content.append(this.background)
if (!this.options.pageVisible) {
this.$content.append(this.graph.view.grid)
}
this.$content.append(graphContainer)
this.$content.appendTo(this.container)
this.startListening()
if (!this.options.pageVisible) {
this.graph.grid.update()
}
this.backgroundManager = new Scroller.Background(this)
if (!this.options.autoResize) {
this.update()
}
}
protected startListening() {
const graph = this.graph
const model = this.model
graph.on('scale', this.onScale, this)
graph.on('resize', this.onResize, this)
graph.on('before:print', this.storeScrollPosition, this)
graph.on('before:export', this.storeScrollPosition, this)
graph.on('after:print', this.restoreScrollPosition, this)
graph.on('after:export', this.restoreScrollPosition, this)
graph.on('render:done', this.onRenderDone, this)
graph.on('unfreeze', this.onUpdate, this)
model.on('reseted', this.onUpdate, this)
model.on('cell:added', this.onUpdate, this)
model.on('cell:removed', this.onUpdate, this)
model.on('cell:changed', this.onUpdate, this)
model.on('batch:stop', this.onBatchStop, this)
this.delegateBackgroundEvents()
}
protected stopListening() {
const graph = this.graph
const model = this.model
graph.off('scale', this.onScale, this)
graph.off('resize', this.onResize, this)
graph.off('beforeprint', this.storeScrollPosition, this)
graph.off('beforeexport', this.storeScrollPosition, this)
graph.off('afterprint', this.restoreScrollPosition, this)
graph.off('afterexport', this.restoreScrollPosition, this)
graph.off('render:done', this.onRenderDone, this)
graph.off('unfreeze', this.onUpdate, this)
model.off('reseted', this.onUpdate, this)
model.off('cell:added', this.onUpdate, this)
model.off('cell:removed', this.onUpdate, this)
model.off('cell:changed', this.onUpdate, this)
model.off('batch:stop', this.onBatchStop, this)
this.undelegateBackgroundEvents()
}
public enableAutoResize() {
this.options.autoResize = true
}
public disableAutoResize() {
this.options.autoResize = false
}
protected onUpdate() {
if (this.graph.isAsync() || !this.options.autoResize) {
return
}
this.update()
}
onBatchStop(args: { name: Model.BatchName }) {
if (this.graph.isAsync() || !this.options.autoResize) {
return
}
if (Renderer.UPDATE_DELAYING_BATCHES.includes(args.name)) {
this.update()
}
}
protected delegateBackgroundEvents(events?: View.Events) {
const evts = events || GraphView.events
this.delegatedHandlers = Object.keys(evts).reduce<{
[name: string]: (...args: any) => any
}>((memo, name) => {
const handler = evts[name]
if (name.indexOf(' ') === -1) {
if (typeof handler === 'function') {
memo[name] = handler as (...args: any) => any
} else {
let method = this.graph.view[handler as keyof GraphView]
if (typeof method === 'function') {
method = method.bind(this.graph.view)
memo[name] = method as (...args: any) => any
}
}
}
return memo
}, {})
this.onBackgroundEvent = this.onBackgroundEvent.bind(this)
Object.keys(this.delegatedHandlers).forEach((name) => {
this.delegateEvent(
name,
{
guarded: false,
},
this.onBackgroundEvent,
)
})
}
protected undelegateBackgroundEvents() {
Object.keys(this.delegatedHandlers).forEach((name) => {
this.undelegateEvent(name, this.onBackgroundEvent)
})
}
protected onBackgroundEvent(e: JQuery.TriggeredEvent) {
let valid = false
const target = e.target
if (!this.options.pageVisible) {
const view = this.graph.view
valid = view.background === target || view.grid === target
} else if (this.options.background) {
valid = this.background === target
} else {
valid = this.content === target
}
if (valid) {
const handler = this.delegatedHandlers[e.type]
if (typeof handler === 'function') {
handler.apply(this.graph, arguments) // eslint-disable-line
}
}
}
protected onRenderDone({ stats }: EventArgs['render:done']) {
if (this.options.autoResize && stats.priority < 2) {
this.update()
}
}
protected onResize() {
if (this.cachedCenterPoint) {
this.centerPoint(this.cachedCenterPoint.x, this.cachedCenterPoint.y)
this.updatePageBreak()
}
}
protected onScale({ sx, sy, ox, oy }: EventArgs['scale']) {
this.updateScale(sx, sy)
if (ox || oy) {
this.centerPoint(ox, oy)
this.updatePageBreak()
}
const autoResizeOptions =
this.options.autoResizeOptions || this.options.fitTocontentOptions
if (typeof autoResizeOptions === 'function') {
this.update()
}
}
protected storeScrollPosition() {
this.cachedScrollLeft = this.container.scrollLeft
this.cachedScrollTop = this.container.scrollTop
}
protected restoreScrollPosition() {
this.container.scrollLeft = this.cachedScrollLeft!
this.container.scrollTop = this.cachedScrollTop!
this.cachedScrollLeft = null
this.cachedScrollTop = null
}
protected storeClientSize() {
this.cachedClientSize = {
width: this.container.clientWidth,
height: this.container.clientHeight,
}
}
protected restoreClientSize() {
this.cachedClientSize = null
}
protected beforeManipulation() {
if (Platform.IS_IE || Platform.IS_EDGE) {
this.$container.css('visibility', 'hidden')
}
}
protected afterManipulation() {
if (Platform.IS_IE || Platform.IS_EDGE) {
this.$container.css('visibility', 'visible')
}
}
public updatePageSize(width?: number, height?: number) {
if (width != null) {
this.options.pageWidth = width
}
if (height != null) {
this.options.pageHeight = height
}
this.updatePageBreak()
}
protected updatePageBreak() {
if (this.pageBreak && this.pageBreak.parentNode) {
this.pageBreak.parentNode.removeChild(this.pageBreak)
}
this.pageBreak = null
if (this.options.pageVisible && this.options.pageBreak) {
const graphWidth = this.graph.options.width
const graphHeight = this.graph.options.height
const pageWidth = this.options.pageWidth! * this.sx
const pageHeight = this.options.pageHeight! * this.sy
if (graphWidth > pageWidth || graphHeight > pageHeight) {
let hasPageBreak = false
const container = document.createElement('div')
for (let i = 1, l = Math.floor(graphWidth / pageWidth); i < l; i += 1) {
this.$('<div/>')
.addClass(this.prefixClassName(`graph-pagebreak-vertical`))
.css({ left: i * pageWidth })
.appendTo(container)
hasPageBreak = true
}
for (
let i = 1, l = Math.floor(graphHeight / pageHeight);
i < l;
i += 1
) {
this.$('<div/>')
.addClass(this.prefixClassName(`graph-pagebreak-horizontal`))
.css({ top: i * pageHeight })
.appendTo(container)
hasPageBreak = true
}
if (hasPageBreak) {
Dom.addClass(container, this.prefixClassName('graph-pagebreak'))
this.$(this.graph.view.grid).after(container)
this.pageBreak = container
}
}
}
}
update() {
const size = this.getClientSize()
this.cachedCenterPoint = this.clientToLocalPoint(
size.width / 2,
size.height / 2,
)
let resizeOptions =
this.options.autoResizeOptions || this.options.fitTocontentOptions
if (typeof resizeOptions === 'function') {
resizeOptions = FunctionExt.call(resizeOptions, this, this)
}
const options: TransformManager.FitToContentFullOptions = {
gridWidth: this.options.pageWidth,
gridHeight: this.options.pageHeight,
allowNewOrigin: 'negative',
...resizeOptions,
}
this.graph.fitToContent(this.getFitToContentOptions(options))
}
protected getFitToContentOptions(
options: TransformManager.FitToContentFullOptions,
) {
const sx = this.sx
const sy = this.sy
options.gridWidth && (options.gridWidth *= sx)
options.gridHeight && (options.gridHeight *= sy)
options.minWidth && (options.minWidth *= sx)
options.minHeight && (options.minHeight *= sy)
if (typeof options.padding === 'object') {
options.padding = {
left: (options.padding.left || 0) * sx,
right: (options.padding.right || 0) * sx,
top: (options.padding.top || 0) * sy,
bottom: (options.padding.bottom || 0) * sy,
}
} else if (typeof options.padding === 'number') {
options.padding *= sx
}
if (!this.options.autoResize) {
options.contentArea = Rectangle.create()
}
return options
}
protected updateScale(sx: number, sy: number) {
const options = this.graph.options
const dx = sx / this.sx
const dy = sy / this.sy
this.sx = sx
this.sy = sy
this.graph.translate(options.x * dx, options.y * dy)
this.graph.resizeGraph(options.width * dx, options.height * dy)
}
scrollbarPosition(): { left: number; top: number }
scrollbarPosition(
left?: number,
top?: number,
options?: Scroller.ScrollOptions,
): this
scrollbarPosition(
left?: number,
top?: number,
options?: Scroller.ScrollOptions,
) {
if (left == null && top == null) {
return {
left: this.container.scrollLeft,
top: this.container.scrollTop,
}
}
const prop: { [key: string]: number } = {}
if (typeof left === 'number') {
prop.scrollLeft = left
}
if (typeof top === 'number') {
prop.scrollTop = top
}
if (options && options.animation) {
this.$container.animate(prop, options.animation)
} else {
this.$container.prop(prop)
}
return this
}
/**
* Try to scroll to ensure that the position (x,y) on the graph (in local
* coordinates) is at the center of the viewport. If only one of the
* coordinates is specified, only scroll in the specified dimension and
* keep the other coordinate unchanged.
*/
scrollToPoint(
x: number | null | undefined,
y: number | null | undefined,
options?: Scroller.ScrollOptions,
) {
const size = this.getClientSize()
const ctm = this.graph.matrix()
const prop: { [key: string]: number } = {}
if (typeof x === 'number') {
prop.scrollLeft = x - size.width / 2 + ctm.e + (this.padding.left || 0)
}
if (typeof y === 'number') {
prop.scrollTop = y - size.height / 2 + ctm.f + (this.padding.top || 0)
}
if (options && options.animation) {
this.$container.animate(prop, options.animation)
} else {
this.$container.prop(prop)
}
return this
}
/**
* Try to scroll to ensure that the center of graph content is at the
* center of the viewport.
*/
scrollToContent(options?: Scroller.ScrollOptions) {
const sx = this.sx
const sy = this.sy
const center = this.graph.getContentArea().getCenter()
return this.scrollToPoint(center.x * sx, center.y * sy, options)
}
/**
* Try to scroll to ensure that the center of cell is at the center of
* the viewport.
*/
scrollToCell(cell: Cell, options?: Scroller.ScrollOptions) {
const sx = this.sx
const sy = this.sy
const center = cell.getBBox().getCenter()
return this.scrollToPoint(center.x * sx, center.y * sy, options)
}
/**
* The center methods are more aggressive than the scroll methods. These
* methods position the graph so that a specific point on the graph lies
* at the center of the viewport, adding paddings around the paper if
* necessary (e.g. if the requested point lies in a corner of the paper).
* This means that the requested point will always move into the center
* of the viewport. (Use the scroll functions to avoid adding paddings
* and only scroll the viewport as far as the graph boundary.)
*/
/**
* Position the center of graph to the center of the viewport.
*/
center(optons?: Scroller.CenterOptions) {
return this.centerPoint(optons)
}
/**
* Position the point (x,y) on the graph (in local coordinates) to the
* center of the viewport. If only one of the coordinates is specified,
* only center along the specified dimension and keep the other coordinate
* unchanged.
*/
centerPoint(
x: number,
y: null | number,
options?: Scroller.CenterOptions,
): this
centerPoint(
x: null | number,
y: number,
options?: Scroller.CenterOptions,
): this
centerPoint(optons?: Scroller.CenterOptions): this
centerPoint(
x?: number | null | Scroller.CenterOptions,
y?: number | null,
options?: Scroller.CenterOptions,
) {
const ctm = this.graph.matrix()
const sx = ctm.a
const sy = ctm.d
const tx = -ctm.e
const ty = -ctm.f
const tWidth = tx + this.graph.options.width
const tHeight = ty + this.graph.options.height
let localOptions: Scroller.CenterOptions | null | undefined
this.storeClientSize() // avoid multilple reflow
if (typeof x === 'number' || typeof y === 'number') {
localOptions = options
const visibleCenter = this.getVisibleArea().getCenter()
if (typeof x === 'number') {
x *= sx // eslint-disable-line
} else {
x = visibleCenter.x // eslint-disable-line
}
if (typeof y === 'number') {
y *= sy // eslint-disable-line
} else {
y = visibleCenter.y // eslint-disable-line
}
} else {
localOptions = x
x = (tx + tWidth) / 2 // eslint-disable-line
y = (ty + tHeight) / 2 // eslint-disable-line
}
if (localOptions && localOptions.padding) {
return this.positionPoint({ x, y }, '50%', '50%', localOptions)
}
const padding = this.getPadding()
const clientSize = this.getClientSize()
const cx = clientSize.width / 2
const cy = clientSize.height / 2
const left = cx - padding.left - x + tx
const right = cx - padding.right + x - tWidth
const top = cy - padding.top - y + ty
const bottom = cy - padding.bottom + y - tHeight
this.addPadding(
Math.max(left, 0),
Math.max(right, 0),
Math.max(top, 0),
Math.max(bottom, 0),
)
const result = this.scrollToPoint(x, y, localOptions || undefined)
this.restoreClientSize()
return result
}
centerContent(options?: Scroller.PositionContentOptions) {
return this.positionContent('center', options)
}
centerCell(cell: Cell, options?: Scroller.CenterOptions) {
return this.positionCell(cell, 'center', options)
}
/**
* The position methods are a more general version of the center methods.
* They position the graph so that a specific point on the graph lies at
* requested coordinates inside the viewport.
*/
/**
*
*/
positionContent(
pos: Scroller.Direction,
options?: Scroller.PositionContentOptions,
) {
const rect = this.graph.getContentArea(options)
return this.positionRect(rect, pos, options)
}
positionCell(
cell: Cell,
pos: Scroller.Direction,
options?: Scroller.CenterOptions,
) {
const bbox = cell.getBBox()
return this.positionRect(bbox, pos, options)
}
positionRect(
rect: Rectangle.RectangleLike,
pos: Scroller.Direction,
options?: Scroller.CenterOptions,
) {
const bbox = Rectangle.create(rect)
switch (pos) {
case 'center':
return this.positionPoint(bbox.getCenter(), '50%', '50%', options)
case 'top':
return this.positionPoint(bbox.getTopCenter(), '50%', 0, options)
case 'top-right':
return this.positionPoint(bbox.getTopRight(), '100%', 0, options)
case 'right':
return this.positionPoint(bbox.getRightMiddle(), '100%', '50%', options)
case 'bottom-right':
return this.positionPoint(
bbox.getBottomRight(),
'100%',
'100%',
options,
)
case 'bottom':
return this.positionPoint(
bbox.getBottomCenter(),
'50%',
'100%',
options,
)
case 'bottom-left':
return this.positionPoint(bbox.getBottomLeft(), 0, '100%', options)
case 'left':
return this.positionPoint(bbox.getLeftMiddle(), 0, '50%', options)
case 'top-left':
return this.positionPoint(bbox.getTopLeft(), 0, 0, options)
default:
return this
}
}
positionPoint(
point: Point.PointLike,
x: number | string,
y: number | string,
options: Scroller.CenterOptions = {},
) {
const { padding: pad, ...localOptions } = options
const padding = NumberExt.normalizeSides(pad)
const clientRect = Rectangle.fromSize(this.getClientSize())
const targetRect = clientRect.clone().moveAndExpand({
x: padding.left,
y: padding.top,
width: -padding.right - padding.left,
height: -padding.top - padding.bottom,
})
// eslint-disable-next-line
x = NumberExt.normalizePercentage(x, Math.max(0, targetRect.width))
if (x < 0) {
x = targetRect.width + x // eslint-disable-line
}
// eslint-disable-next-line
y = NumberExt.normalizePercentage(y, Math.max(0, targetRect.height))
if (y < 0) {
y = targetRect.height + y // eslint-disable-line
}
const origin = targetRect.getTopLeft().translate(x, y)
const diff = clientRect.getCenter().diff(origin)
const scale = this.zoom()
const rawDiff = diff.scale(1 / scale, 1 / scale)
const result = Point.create(point).translate(rawDiff)
return this.centerPoint(result.x, result.y, localOptions)
}
zoom(): number
zoom(factor: number, options?: TransformManager.ZoomOptions): this
zoom(factor?: number, options?: TransformManager.ZoomOptions) {
if (factor == null) {
return this.sx
}
options = options || {} // eslint-disable-line
let cx
let cy
const clientSize = this.getClientSize()
const center = this.clientToLocalPoint(
clientSize.width / 2,
clientSize.height / 2,
)
let sx = factor
let sy = factor
if (!options.absolute) {
sx += this.sx
sy += this.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)
}
sx = this.graph.transform.clampScale(sx)
sy = this.graph.transform.clampScale(sy)
if (options.center) {
const fx = sx / this.sx
const fy = sy / this.sy
cx = options.center.x - (options.center.x - center.x) / fx
cy = options.center.y - (options.center.y - center.y) / fy
} else {
cx = center.x
cy = center.y
}
this.beforeManipulation()
this.graph.transform.scale(sx, sy)
this.centerPoint(cx, cy)
this.afterManipulation()
return this
}
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.$container.width()!,
height: this.$container.height()!,
}
}
this.beforeManipulation()
graph.transform.scaleContentToFitImpl(options, false)
const center = area.getCenter()
this.centerPoint(center.x, center.y)
this.afterManipulation()
return this
}
zoomToFit(
options: TransformManager.GetContentAreaOptions &
TransformManager.ScaleContentToFitOptions = {},
) {
return this.zoomToRect(this.graph.getContentArea(options), options)
}
transitionToPoint(
p: Point.PointLike,
options?: Scroller.TransitionOptions,
): this
transitionToPoint(
x: number,
y: number,
options?: Scroller.TransitionOptions,
): this
transitionToPoint(
x: number | Point.PointLike,
y?: number | Scroller.TransitionOptions,
options?: Scroller.TransitionOptions,
) {
if (typeof x === 'object') {
options = y as Scroller.TransitionOptions // eslint-disable-line
y = x.y // eslint-disable-line
x = x.x // eslint-disable-line
} else {
y = y as number // eslint-disable-line
}
if (options == null) {
options = {} // eslint-disable-line
}
let transform
let transformOrigin
const scale = this.sx
const targetScale = Math.max(options.scale || scale, 0.000001)
const clientSize = this.getClientSize()
const targetPoint = new Point(x, y)
const localPoint = this.clientToLocalPoint(
clientSize.width / 2,
clientSize.height / 2,
)
if (scale === targetScale) {
const translate = localPoint.diff(targetPoint).scale(scale, scale).round()
transform = `translate(${translate.x}px,${translate.y}px)`
} else {
const delta =
(targetScale / (scale - targetScale)) * targetPoint.distance(localPoint)
const range = localPoint.clone().move(targetPoint, delta)
const origin = this.localToBackgroundPoint(range).round()
transform = `scale(${targetScale / scale})`
transformOrigin = `${origin.x}px ${origin.y}px`
}
const onTransitionEnd = options.onTransitionEnd
this.$container.addClass(Util.transitionClassName)
this.$content
.off(Util.transitionEventName)
.on(Util.transitionEventName, (e) => {
this.syncTransition(targetScale, { x: x as number, y: y as number })
if (typeof onTransitionEnd === 'function') {
FunctionExt.call(
onTransitionEnd,
this,
e.originalEvent as TransitionEvent,
)
}
})
.css({
transform,
transformOrigin,
transition: 'transform',
transitionDuration: options.duration || '1s',
transitionDelay: options.delay,
transitionTimingFunction: options.timing,
} as JQuery.PlainObject<string>)
return this
}
protected syncTransition(scale: number, p: Point.PointLike) {
this.beforeManipulation()
this.graph.scale(scale)
this.removeTransition()
this.centerPoint(p.x, p.y)
this.afterManipulation()
return this
}
protected removeTransition() {
this.$container.removeClass(Util.transitionClassName)
this.$content.off(Util.transitionEventName).css({
transform: '',
transformOrigin: '',
transition: '',
transitionDuration: '',
transitionDelay: '',
transitionTimingFunction: '',
})
return this
}
transitionToRect(
rectangle: Rectangle.RectangleLike,
options: Scroller.TransitionToRectOptions = {},
) {
const rect = Rectangle.create(rectangle)
const maxScale = options.maxScale || Infinity
const minScale = options.minScale || Number.MIN_VALUE
const scaleGrid = options.scaleGrid || null
const PIXEL_SIZE = options.visibility || 1
const center = options.center
? Point.create(options.center)
: rect.getCenter()
const clientSize = this.getClientSize()
const w = clientSize.width * PIXEL_SIZE
const h = clientSize.height * PIXEL_SIZE
let scale = new Rectangle(
center.x - w / 2,
center.y - h / 2,
w,
h,
).getMaxUniformScaleToFit(rect, center)
scale = Math.min(scale, maxScale)
if (scaleGrid) {
scale = Math.floor(scale / scaleGrid) * scaleGrid
}
scale = Math.max(minScale, scale)
return this.transitionToPoint(center, {
scale,
...options,
})
}
startPanning(evt: JQuery.MouseDownEvent) {
const e = this.normalizeEvent(evt)
this.clientX = e.clientX
this.clientY = e.clientY
this.trigger('pan:start', { e })
this.$(document.body).on({
'mousemove.panning touchmove.panning': this.pan.bind(this),
'mouseup.panning touchend.panning': this.stopPanning.bind(this),
})
this.$(window).on('mouseup.panning', this.stopPanning.bind(this))
}
pan(evt: JQuery.MouseMoveEvent) {
const e = this.normalizeEvent(evt)
const dx = e.clientX - this.clientX
const dy = e.clientY - this.clientY
this.container.scrollTop -= dy
this.container.scrollLeft -= dx
this.clientX = e.clientX
this.clientY = e.clientY
this.trigger('panning', { e })
}
stopPanning(e: JQuery.MouseUpEvent) {
this.$(document.body).off('.panning')
this.$(window).off('.panning')
this.trigger('pan:stop', { e })
}
clientToLocalPoint(p: Point.PointLike): Point
clientToLocalPoint(x: number, y: number): Point
clientToLocalPoint(a: number | Point.PointLike, b?: number) {
let x = typeof a === 'object' ? a.x : a
let y = typeof a === 'object' ? a.y : (b as number)
const ctm = this.graph.matrix()
x += this.container.scrollLeft - this.padding.left - ctm.e
y += this.container.scrollTop - this.padding.top - ctm.f
return new Point(x / ctm.a, y / ctm.d)
}
localToBackgroundPoint(p: Point.PointLike): Point
localToBackgroundPoint(x: number, y: number): Point
localToBackgroundPoint(x: number | Point.PointLike, y?: number) {
const p = typeof x === 'object' ? Point.create(x) : new Point(x, y)
const ctm = this.graph.matrix()
const padding = this.padding
return Dom.transformPoint(p, ctm).translate(padding.left, padding.top)
}
resize(width?: number, height?: number) {
let w = width != null ? width : this.container.clientWidth
let h = height != null ? height : this.container.clientHeight
if (typeof w === 'number') {
w = Math.round(w)
}
if (typeof h === 'number') {
h = Math.round(h)
}
this.options.width = w
this.options.height = h
this.$container.css({ width: w, height: h })
this.update()
}
getClientSize() {
if (this.cachedClientSize) {
return this.cachedClientSize
}
return {
width: this.container.clientWidth,
height: this.container.clientHeight,
}
}
autoScroll(clientX: number, clientY: number) {
const buffer = 10
const container = this.container
const rect = container.getBoundingClientRect()
let dx = 0
let dy = 0
if (clientX <= rect.left + buffer) {
dx = -buffer
}
if (clientY <= rect.top + buffer) {
dy = -buffer
}
if (clientX >= rect.right - buffer) {
dx = buffer
}
if (clientY >= rect.bottom - buffer) {
dy = buffer
}
if (dx !== 0) {
container.scrollLeft += dx
}
if (dy !== 0) {
container.scrollTop += dy
}
return {
scrollerX: dx,
scrollerY: dy,
}
}
protected addPadding(
left?: number,
right?: number,
top?: number,
bottom?: number,
) {
let padding = this.getPadding()
this.padding = {
left: Math.round(padding.left + (left || 0)),
top: Math.round(padding.top + (top || 0)),
bottom: Math.round(padding.bottom + (bottom || 0)),
right: Math.round(padding.right + (right || 0)),
}
padding = this.padding
this.$content.css({
width: padding.left + this.graph.options.width + padding.right,
height: padding.top + this.graph.options.height + padding.bottom,
})
const container = this.graph.container
container.style.left = `${this.padding.left}px`
container.style.top = `${this.padding.top}px`
return this
}
protected getPadding() {
const padding = this.options.padding
if (typeof padding === 'function') {
return NumberExt.normalizeSides(FunctionExt.call(padding, this, this))
}
return NumberExt.normalizeSides(padding)
}
/**
* Returns the untransformed size and origin of the current viewport.
*/
getVisibleArea() {
const ctm = this.graph.matrix()
const size = this.getClientSize()
const box = {
x: this.container.scrollLeft || 0,
y: this.container.scrollTop || 0,
width: size.width,
height: size.height,
}
const area = Dom.transformRectangle(box, ctm.inverse())
area.x -= (this.padding.left || 0) / this.sx
area.y -= (this.padding.top || 0) / this.sy
return area
}
isCellVisible(cell: Cell, options: { strict?: boolean } = {}) {
const bbox = cell.getBBox()
const area = this.getVisibleArea()
return options.strict
? area.containsRect(bbox)
: area.isIntersectWithRect(bbox)
}
isPointVisible(point: Point.PointLike) {
return this.getVisibleArea().containsPoint(point)
}
/**
* Lock the current viewport by disabling user scrolling.
*/
lock() {
this.$container.css('overflow', 'hidden')
return this
}
/**
* Enable user scrolling if previously locked.
*/
unlock() {
this.$container.css('overflow', 'scroll')
return this
}
protected onRemove() {
this.stopListening()
}
.dispose()
dispose() {
this.$(this.graph.container).insertBefore(this.$container)
this.remove()
}
}
export namespace Scroller {
export interface CommonOptions {
className?: string
width?: number
height?: number
pageWidth?: number
pageHeight?: number
pageVisible?: boolean
pageBreak?: boolean
minVisibleWidth?: number
minVisibleHeight?: number
background?: false | BackgroundManager.Options
autoResize?: boolean
padding?:
| NumberExt.SideOptions
| ((this: Scroller, scroller: Scroller) => NumberExt.SideOptions)
/**
* **Deprecation Notice:** Scroller option `fitTocontentOptions` is deprecated and will be
* moved in next major release. Use `autoResizeOptions` instead.
*
* @deprecated
*/
fitTocontentOptions?:
| TransformManager.FitToContentFullOptions
| ((
this: Scroller,
scroller: Scroller,
) => TransformManager.FitToContentFullOptions)
autoResizeOptions?:
| TransformManager.FitToContentFullOptions
| ((
this: Scroller,
scroller: Scroller,
) => TransformManager.FitToContentFullOptions)
}
export interface Options extends CommonOptions {
graph: Graph
}
export interface ScrollOptions {
animation?: JQuery.EffectsOptions<HTMLElement>
}
export interface CenterOptions extends ScrollOptions {
padding?: NumberExt.SideOptions
}
export type PositionContentOptions = TransformManager.GetContentAreaOptions &
Scroller.CenterOptions
export type Direction =
| 'center'
| 'top'
| 'top-right'
| 'top-left'
| 'right'
| 'bottom-right'
| 'bottom'
| 'bottom-left'
| 'left'
export interface TransitionOptions {
/**
* The zoom level to reach at the end of the transition.
*/
scale?: number
duration?: string
delay?: string
timing?: string
onTransitionEnd?: (this: Scroller, e: TransitionEvent) => void
}
export interface TransitionToRectOptions extends TransitionOptions {
minScale?: number
maxScale?: number
scaleGrid?: number
visibility?: number
center?: Point.PointLike
}
}
export namespace Scroller {
export class Background extends BackgroundManager {
protected readonly scroller: Scroller
protected get elem() {
return this.scroller.background
}
constructor(scroller: Scroller) {
super(scroller.graph)
this.scroller = scroller
if (scroller.options.background) {
this.draw(scroller.options.background)
}
}
protected init() {
this.graph.on('scale', this.update, this)
this.graph.on('translate', this.update, this)
}
protected updateBackgroundOptions(options?: BackgroundManager.Options) {
this.scroller.options.background = options
}
}
}
namespace Util {
export const containerClass = 'graph-scroller'
export const panningClass = `${containerClass}-panning`
export const pannableClass = `${containerClass}-pannable`
export const pagedClass = `${containerClass}-paged`
export const contentClass = `${containerClass}-content`
export const backgroundClass = `${containerClass}-background`
export const transitionClassName = 'transition-in-progress'
export const transitionEventName = 'transitionend.graph-scroller-transition'
export const defaultOptions: Partial<Scroller.Options> = {
padding() {
const size = this.getClientSize()
const minWidth = Math.max(this.options.minVisibleWidth || 0, 1) || 1
const minHeight = Math.max(this.options.minVisibleHeight || 0, 1) || 1
const left = Math.max(size.width - minWidth, 0)
const top = Math.max(size.height - minHeight, 0)
return { left, top, right: left, bottom: top }
},
minVisibleWidth: 50,
minVisibleHeight: 50,
pageVisible: false,
pageBreak: false,
autoResize: true,
}
export function getOptions(options: Scroller.Options) {
const result = ObjectExt.merge({}, defaultOptions, options)
if (result.pageWidth == null) {
result.pageWidth = options.graph.options.width
}
if (result.pageHeight == null) {
result.pageHeight = options.graph.options.height
}
return result as Scroller.Options
}
}