@antv/x6
Version:
JavaScript diagramming library that uses SVG and HTML for rendering
344 lines (292 loc) • 9.74 kB
text/typescript
import { CssLoader, Dom, disposable, FunctionExt } from '../../common'
import { type EventArgs, Graph, Options, GraphPlugin } from '../../graph'
import { View } from '../../view'
import { content } from './style/raw'
import type {
MiniMapEventData,
MiniMapOptions,
MiniMapViewGeometry,
} from './type'
const DefaultOptions: Partial<MiniMapOptions> = {
width: 300,
height: 200,
padding: 10,
scalable: true,
minScale: 0.01,
maxScale: 16,
graphOptions: {},
createGraph: (options) => new Graph(options),
}
const DocumentEvents = {
mousemove: 'doAction',
touchmove: 'doAction',
mouseup: 'stopAction',
touchend: 'stopAction',
}
const RootClassName = 'widget-minimap'
const ViewportClassName = `${RootClassName}-viewport`
const ZoomClassName = `${ViewportClassName}-zoom`
export class MiniMap extends View implements GraphPlugin {
public name = 'minimap'
private graph: Graph
public readonly options: MiniMapOptions
public declare container: HTMLDivElement
protected zoomHandle: HTMLDivElement
protected viewport: HTMLElement
protected sourceGraph: Graph
protected targetGraph: Graph
protected geometry: MiniMapViewGeometry
protected ratio: number
// Marks whether targetGraph is being transformed or scaled
// If yes we update updateViewport only
private targetGraphTransforming: boolean
protected get scroller() {
return this.graph.getPlugin<any>('scroller')
}
protected get graphContainer() {
if (this.scroller) {
return this.scroller.container
}
return this.graph.container
}
constructor(options: Partial<MiniMapOptions>) {
super()
this.options = {
...DefaultOptions,
...options,
} as MiniMapOptions
CssLoader.ensure(this.name, content)
}
public init(graph: Graph) {
this.graph = graph
this.updateViewport = FunctionExt.debounce(
this.updateViewport.bind(this),
0,
)
this.container = document.createElement('div')
Dom.addClass(this.container, this.prefixClassName(RootClassName))
const graphContainer = document.createElement('div')
this.container.appendChild(graphContainer)
this.viewport = document.createElement('div')
Dom.addClass(this.viewport, this.prefixClassName(ViewportClassName))
if (this.options.scalable) {
this.zoomHandle = document.createElement('div')
Dom.addClass(this.zoomHandle, this.prefixClassName(ZoomClassName))
Dom.appendTo(this.zoomHandle, this.viewport)
}
Dom.append(this.container, this.viewport)
Dom.css(this.container, {
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: Options = {
...this.options.graphOptions,
container: graphContainer,
model: this.sourceGraph.model,
interacting: false,
grid: false,
background: false,
embedding: false,
panning: false,
}
this.targetGraph = this.options.createGraph
? this.options.createGraph(targetGraphOptions)
: new Graph(targetGraphOptions)
this.updatePaper(
this.sourceGraph.options.width,
this.sourceGraph.options.height,
)
this.startListening()
}
protected startListening() {
if (this.scroller) {
Dom.Event.on(
this.graphContainer,
`scroll${this.getEventNamespace()}`,
this.updateViewport,
)
} else {
this.sourceGraph.on('translate', this.onTransform, this)
this.sourceGraph.on('scale', this.onTransform, this)
this.sourceGraph.on('model:updated', this.onModelUpdated, 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) {
Dom.Event.off(this.graphContainer, this.getEventNamespace())
} else {
this.sourceGraph.off('translate', this.onTransform, this)
this.sourceGraph.off('scale', this.onTransform, this)
this.sourceGraph.off('model:updated', this.onModelUpdated, this)
}
this.sourceGraph.off('resize', this.updatePaper, this)
this.undelegateEvents()
}
protected onRemove() {
this.stopListening()
this.targetGraph.dispose(false)
}
protected onTransform(options: { ui: boolean }) {
if (options.ui || this.targetGraphTransforming || !this.scroller) {
this.updateViewport()
}
}
protected onModelUpdated() {
this.targetGraph.zoomToFit()
}
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.resize(width, height)
this.targetGraph.translate(x, y)
if (this.scroller) {
this.targetGraph.scale(ratio, ratio)
} else {
this.targetGraph.zoomToFit()
}
this.updateViewport()
return this
}
protected updateViewport() {
const sourceGraphScale = this.sourceGraph.transform.getScale()
const targetGraphScale = this.targetGraph.transform.getScale()
const origin = this.scroller
? this.scroller.clientToLocalPoint(0, 0)
: this.graph.graphToLocal(0, 0)
const position = Dom.position(this.targetGraph.container)
const translation = this.targetGraph.translate()
translation.ty = translation.ty || 0
this.geometry = {
top: position.top + origin.y * targetGraphScale.sy + translation.ty,
left: position.left + origin.x * targetGraphScale.sx + translation.tx,
width:
(this.graphContainer.clientWidth! * targetGraphScale.sx) /
sourceGraphScale.sx,
height:
(this.graphContainer.clientHeight! * targetGraphScale.sy) /
sourceGraphScale.sy,
}
Dom.css(this.viewport, this.geometry)
}
protected startAction(evt: Dom.MouseDownEvent) {
const e = this.normalizeEvent(evt)
const action = e.target === this.zoomHandle ? 'zooming' : 'panning'
const { tx, ty } = this.sourceGraph.translate()
const eventData: MiniMapEventData = {
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.targetGraphTransforming = true
this.delegateDocumentEvents(DocumentEvents, eventData)
}
protected doAction(evt: Dom.MouseMoveEvent) {
const e = this.normalizeEvent(evt)
const clientX = e.clientX
const clientY = e.clientY
const data = e.data as MiniMapEventData
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()
this.targetGraphTransforming = false
}
protected scrollTo(evt: Dom.MouseDownEvent) {
const e = this.normalizeEvent(evt)
let x: number
let y: number
const ts = this.targetGraph.translate()
ts.ty = ts.ty || 0
if (e.offsetX == null) {
const offset = Dom.offset(this.targetGraph.container)
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() {
this.remove()
CssLoader.clean(this.name)
}
}