@antv/x6
Version:
JavaScript diagramming library that uses SVG and HTML for rendering.
372 lines (319 loc) • 9.96 kB
text/typescript
import { FunctionExt } from '../../util'
import { View } from '../../view/view'
import { Graph } from '../../graph/graph'
import { EventArgs } from '../../graph/events'
import { Point } from '../../geometry'
namespace ClassName {
export const root = 'widget-minimap'
export const viewport = `${root}-viewport`
export const zoom = `${viewport}-zoom`
}
export class MiniMap extends View {
public readonly options: MiniMap.Options
public readonly container: HTMLDivElement
public readonly $container: JQuery<HTMLElement>
protected readonly zoomHandle: HTMLDivElement
protected readonly $viewport: JQuery<HTMLElement>
protected readonly sourceGraph: Graph
protected readonly targetGraph: Graph
protected geometry: Util.ViewGeometry
protected ratio: number
protected get graph() {
return this.options.graph
}
protected get scroller() {
return this.graph.scroller.widget
}
protected get graphContainer() {
if (this.scroller) {
return this.scroller.container
}
return this.graph.container
}
protected get $graphContainer() {
if (this.scroller) {
return this.scroller.$container
}
return this.$(this.graph.container)
}
constructor(options: Partial<MiniMap.Options> & { graph: Graph }) {
super()
this.options = {
...Util.defaultOptions,
...options,
} as MiniMap.Options
this.updateViewport = FunctionExt.debounce(
this.updateViewport.bind(this),
0,
)
this.container = document.createElement('div')
this.$container = this.$(this.container).addClass(
this.prefixClassName(ClassName.root),
)
const graphContainer = document.createElement('div')
this.container.appendChild(graphContainer)
this.$viewport = this.$('<div>').addClass(
this.prefixClassName(ClassName.viewport),
)
if (this.options.scalable) {
this.zoomHandle = this.$('<div>')
.addClass(this.prefixClassName(ClassName.zoom))
.appendTo(this.$viewport)
.get(0)
}
this.$container.append(this.$viewport).css({
width: this.options.width,
height: this.options.height,
padding: this.options.padding,
})
if (this.options.container) {
this.options.container.appendChild(this.container)
}
this.sourceGraph = this.graph
const targetGraphOptions: Graph.Options = {
...this.options.graphOptions,
container: graphContainer,
model: this.sourceGraph.model,
frozen: true,
async: this.sourceGraph.isAsync(),
interacting: false,
grid: false,
background: false,
rotating: false,
resizing: false,
embedding: false,
selecting: false,
snapline: false,
clipboard: false,
history: false,
scroller: false,
}
this.targetGraph = this.options.createGraph
? this.options.createGraph(targetGraphOptions)
: new Graph(targetGraphOptions)
this.targetGraph.renderer.unfreeze()
this.updatePaper(
this.sourceGraph.options.width,
this.sourceGraph.options.height,
)
this.startListening()
}
protected startListening() {
if (this.scroller) {
this.$graphContainer.on(
`scroll${this.getEventNamespace()}`,
this.updateViewport,
)
} else {
this.sourceGraph.on('translate', this.updateViewport, this)
}
this.sourceGraph.on('resize', this.updatePaper, this)
this.delegateEvents({
mousedown: 'startAction',
touchstart: 'startAction',
[`mousedown .${this.prefixClassName('graph')}`]: 'scrollTo',
[`touchstart .${this.prefixClassName('graph')}`]: 'scrollTo',
})
}
protected stopListening() {
if (this.scroller) {
this.$graphContainer.off(this.getEventNamespace())
} else {
this.sourceGraph.off('translate', this.updateViewport, this)
}
this.sourceGraph.off('resize', this.updatePaper, this)
this.undelegateEvents()
}
protected onRemove() {
this.targetGraph.view.remove()
this.stopListening()
this.targetGraph.dispose()
}
protected updatePaper(width: number, height: number): this
protected updatePaper({ width, height }: EventArgs['resize']): this
protected updatePaper(w: number | EventArgs['resize'], h?: number) {
let width: number
let height: number
if (typeof w === 'object') {
width = w.width
height = w.height
} else {
width = w
height = h as number
}
const origin = this.sourceGraph.options
const scale = this.sourceGraph.transform.getScale()
const maxWidth = this.options.width - 2 * this.options.padding
const maxHeight = this.options.height - 2 * this.options.padding
width /= scale.sx // eslint-disable-line
height /= scale.sy // eslint-disable-line
this.ratio = Math.min(maxWidth / width, maxHeight / height)
const ratio = this.ratio
const x = (origin.x * ratio) / scale.sx
const y = (origin.y * ratio) / scale.sy
width *= ratio // eslint-disable-line
height *= ratio // eslint-disable-line
this.targetGraph.resizeGraph(width, height)
this.targetGraph.translate(x, y)
this.targetGraph.scale(ratio, ratio)
this.updateViewport()
return this
}
protected updateViewport() {
const ratio = this.ratio
const scale = this.sourceGraph.transform.getScale()
let origin = null
if (this.scroller) {
origin = this.scroller.clientToLocalPoint(0, 0)
} else {
const ctm = this.sourceGraph.matrix()
origin = new Point(-ctm.e / ctm.a, -ctm.f / ctm.d)
}
const position = this.$(this.targetGraph.container).position()
const translation = this.targetGraph.translate()
translation.ty = translation.ty || 0
this.geometry = {
top: position.top + origin.y * ratio + translation.ty,
left: position.left + origin.x * ratio + translation.tx,
width: (this.$graphContainer.innerWidth()! * ratio) / scale.sx,
height: (this.$graphContainer.innerHeight()! * ratio) / scale.sy,
}
this.$viewport.css(this.geometry)
}
protected startAction(evt: JQuery.MouseDownEvent) {
const e = this.normalizeEvent(evt)
const action = e.target === this.zoomHandle ? 'zooming' : 'panning'
const { tx, ty } = this.sourceGraph.translate()
const eventData: Util.EventData = {
action,
clientX: e.clientX,
clientY: e.clientY,
scrollLeft: this.graphContainer.scrollLeft,
scrollTop: this.graphContainer.scrollTop,
zoom: this.sourceGraph.zoom(),
scale: this.sourceGraph.transform.getScale(),
geometry: this.geometry,
translateX: tx,
translateY: ty,
}
this.delegateDocumentEvents(Util.documentEvents, eventData)
}
protected doAction(evt: JQuery.MouseMoveEvent) {
const e = this.normalizeEvent(evt)
const clientX = e.clientX
const clientY = e.clientY
const data = e.data as Util.EventData
switch (data.action) {
case 'panning': {
const scale = this.sourceGraph.transform.getScale()
const rx = (clientX - data.clientX) * scale.sx
const ry = (clientY - data.clientY) * scale.sy
if (this.scroller) {
this.graphContainer.scrollLeft = data.scrollLeft + rx / this.ratio
this.graphContainer.scrollTop = data.scrollTop + ry / this.ratio
} else {
this.sourceGraph.translate(
data.translateX - rx / this.ratio,
data.translateY - ry / this.ratio,
)
}
break
}
case 'zooming': {
const startScale = data.scale
const startGeometry = data.geometry
const delta =
1 + (data.clientX - clientX) / startGeometry.width / startScale.sx
if (data.frameId) {
cancelAnimationFrame(data.frameId)
}
data.frameId = requestAnimationFrame(() => {
this.sourceGraph.zoom(delta * data.zoom, {
absolute: true,
minScale: this.options.minScale,
maxScale: this.options.maxScale,
})
})
break
}
default:
break
}
}
protected stopAction() {
this.undelegateDocumentEvents()
}
protected scrollTo(evt: JQuery.MouseDownEvent) {
const e = this.normalizeEvent(evt)
let x
let y
const ts = this.targetGraph.translate()
ts.ty = ts.ty || 0
if (e.offsetX == null) {
const offset = this.$(this.targetGraph.container).offset()!
x = e.pageX - offset.left
y = e.pageY - offset.top
} else {
x = e.offsetX
y = e.offsetY
}
const cx = (x - ts.tx) / this.ratio
const cy = (y - ts.ty) / this.ratio
this.sourceGraph.centerPoint(cx, cy)
}
.dispose()
dispose() {
this.remove()
}
}
export namespace MiniMap {
export interface Options {
graph: Graph
container?: HTMLElement
width: number
height: number
padding: number
scalable?: boolean
minScale?: number
maxScale?: number
createGraph?: (options: Graph.Options) => Graph
graphOptions?: Graph.Options
}
}
namespace Util {
export const defaultOptions: Partial<MiniMap.Options> = {
width: 300,
height: 200,
padding: 10,
scalable: true,
minScale: 0.01,
maxScale: 16,
graphOptions: {},
createGraph: (options) => new Graph(options),
}
export const documentEvents = {
mousemove: 'doAction',
touchmove: 'doAction',
mouseup: 'stopAction',
touchend: 'stopAction',
}
export interface ViewGeometry extends JQuery.PlainObject<number> {
top: number
left: number
width: number
height: number
}
export interface EventData {
frameId?: number
action: 'zooming' | 'panning'
clientX: number
clientY: number
scrollLeft: number
scrollTop: number
zoom: number
scale: { sx: number; sy: number }
geometry: ViewGeometry
translateX: number
translateY: number
}
}