@antv/x6
Version:
JavaScript diagramming library that uses SVG and HTML for rendering
606 lines (523 loc) • 15.4 kB
text/typescript
import {
Disposable,
Dom,
disposable,
FunctionExt,
type KeyValue,
} from '../common'
import { FLAG_INSERT, FLAG_REMOVE } from '../constants'
import type { Rectangle } from '../geometry'
import type { Graph } from '../graph'
import type { Cell, ModelEventArgs } from '../model'
import { CellView, EdgeView, NodeView, type View } from '../view'
import type { FlagManagerAction } from '../view/flag'
import { JOB_PRIORITY, JobQueue } from './queueJob'
export enum SchedulerViewState {
CREATED,
MOUNTED,
WAITING,
}
export interface SchedulerView {
view: CellView
flag: number
options: KeyValue
state: SchedulerViewState
}
export interface SchedulerEventArgs {
'view:mounted': { view: CellView }
'view:unmounted': { view: CellView }
'render:done': null
}
export class Scheduler extends Disposable {
public views: KeyValue<SchedulerView> = {}
public willRemoveViews: KeyValue<SchedulerView> = {}
protected zPivots: KeyValue<Comment>
private graph: Graph
private renderArea?: Rectangle
private queue: JobQueue
get model() {
return this.graph.model
}
get container() {
return this.graph.view.stage
}
constructor(graph: Graph) {
super()
this.queue = new JobQueue()
this.graph = graph
this.init()
}
protected init() {
this.startListening()
this.renderViews(this.model.getCells())
}
protected startListening() {
this.model.on('reseted', this.onModelReseted, this)
this.model.on('cell:added', this.onCellAdded, this)
this.model.on('cell:removed', this.onCellRemoved, this)
this.model.on('cell:change:zIndex', this.onCellZIndexChanged, this)
this.model.on('cell:change:visible', this.onCellVisibleChanged, this)
}
protected stopListening() {
this.model.off('reseted', this.onModelReseted, this)
this.model.off('cell:added', this.onCellAdded, this)
this.model.off('cell:removed', this.onCellRemoved, this)
this.model.off('cell:change:zIndex', this.onCellZIndexChanged, this)
this.model.off('cell:change:visible', this.onCellVisibleChanged, this)
}
protected onModelReseted({ options, previous }: ModelEventArgs['reseted']) {
let cells = this.model.getCells()
if (!options?.diff) {
this.queue.clearJobs()
this.removeZPivots()
this.resetViews()
} else {
const previousSet = new Set(previous)
cells = cells.filter((cell) => !previousSet.has(cell))
}
this.renderViews(cells, { ...options, queue: cells.map((cell) => cell.id) })
}
protected onCellAdded({ cell, options }: ModelEventArgs['cell:added']) {
this.renderViews([cell], options)
}
protected onCellRemoved({ cell }: ModelEventArgs['cell:removed']) {
this.removeViews([cell])
}
protected onCellZIndexChanged({
cell,
options,
}: ModelEventArgs['cell:change:zIndex']) {
const viewItem = this.views[cell.id]
if (viewItem) {
this.requestViewUpdate(
viewItem.view,
FLAG_INSERT,
options,
JOB_PRIORITY.Update,
true,
)
}
}
protected onCellVisibleChanged({
cell,
current,
}: ModelEventArgs['cell:change:visible']) {
this.toggleVisible(cell, !!current)
}
requestViewUpdate(
view: CellView,
flag: number,
options: KeyValue = {},
priority: JOB_PRIORITY = JOB_PRIORITY.Update,
flush = true,
) {
const id = view.cell.id
const viewItem = this.views[id]
if (!viewItem) {
return
}
const nextFlag = viewItem.flag | flag
viewItem.flag = nextFlag
const prevOptions = viewItem.options || {}
const nextOptions = options || {}
if (prevOptions.queue && nextOptions.queue == null) {
nextOptions.queue = prevOptions.queue
}
if (prevOptions.async === false || nextOptions.async === false) {
nextOptions.async = false
}
viewItem.options = nextOptions
const priorAction = view.hasAction(flag, ['translate', 'resize', 'rotate'])
if (priorAction || nextOptions.async === false) {
priority = JOB_PRIORITY.PRIOR // eslint-disable-line
flush = false // eslint-disable-line
}
this.queue.queueJob({
id,
priority,
cb: () => {
const current = this.views[id]
if (!current) return
const currentOptions = current.options || {}
this.renderViewInArea(current.view, current.flag, currentOptions)
const queue = currentOptions.queue
if (queue) {
const index = queue.indexOf(current.view.cell.id)
if (index >= 0) {
queue.splice(index, 1)
}
if (queue.length === 0) {
this.graph.trigger('render:done')
}
}
},
})
const effectedEdges = this.getEffectedEdges(view)
effectedEdges.forEach((edge) => {
this.requestViewUpdate(edge.view, edge.flag, options, priority, false)
})
if (flush) {
this.flush()
}
}
setRenderArea(area?: Rectangle) {
this.renderArea = area
// 当可视渲染区域变化时,卸载不在区域内且已挂载的视图
Object.values(this.views).forEach((viewItem) => {
if (!viewItem) return
const { view } = viewItem
if (viewItem.state === SchedulerViewState.MOUNTED) {
if (!this.isUpdatable(view)) {
// 卸载 DOM
view.remove()
this.graph.trigger('view:unmounted', { view })
// 切换到 WAITING 状态,等待重新进入区域时再插入
viewItem.state = SchedulerViewState.WAITING
// 确保重新进入可视区域后会重新插入,并执行视图的 render 等动作,让 react 等节点能重新展示
viewItem.flag |= FLAG_INSERT | view.getBootstrapFlag()
}
}
})
this.flushWaitingViews()
}
isViewMounted(view: CellView) {
if (view == null) {
return false
}
const viewItem = this.views[view.cell.id]
if (!viewItem) {
return false
}
return viewItem.state === SchedulerViewState.MOUNTED
}
protected renderViews(cells: Cell[], options: any = {}) {
cells.sort((c1, c2) => {
if (c1.isNode() && c2.isEdge()) {
return -1
}
return 0
})
cells.forEach((cell) => {
const id = cell.id
const views = this.views
let flag = 0
let viewItem = views[id]
if (viewItem) {
flag = FLAG_INSERT
} else {
const cellView = this.createCellView(cell)
if (cellView) {
cellView.graph = this.graph
flag = FLAG_INSERT | cellView.getBootstrapFlag()
viewItem = {
view: cellView,
flag,
options,
state: SchedulerViewState.CREATED,
}
this.views[id] = viewItem
}
}
if (viewItem) {
this.requestViewUpdate(
viewItem.view,
flag,
options,
this.getRenderPriority(viewItem.view),
false,
)
}
})
this.flush()
}
protected renderViewInArea(view: CellView, flag: number, options: any = {}) {
const cell = view.cell
const id = cell.id
const viewItem = this.views[id]
if (!viewItem) {
return
}
let result = 0
if (this.isUpdatable(view)) {
result = this.updateView(view, flag, options)
viewItem.flag = result
} else {
// 视图不在当前可渲染区域内
if (viewItem.state === SchedulerViewState.MOUNTED) {
// 将已挂载但不在可视区域的视图从 DOM 中卸载
view.remove()
this.graph.trigger('view:unmounted', { view })
result = 0
}
// 标记为 WAITING 状态,以便在可视区域变化时重新渲染
viewItem.state = SchedulerViewState.WAITING
// 确保重新进入可视区域时能够重新插入到 DOM
viewItem.flag = flag | FLAG_INSERT | view.getBootstrapFlag()
}
if (result) {
if (
cell.isEdge() &&
(result & view.getFlag(['source', 'target'])) === 0
) {
this.queue.queueJob({
id,
priority: JOB_PRIORITY.RenderEdge,
cb: () => {
this.updateView(view, flag, options)
},
})
}
}
}
protected removeViews(cells: Cell[]) {
cells.forEach((cell) => {
const id = cell.id
const viewItem = this.views[id]
if (viewItem) {
this.willRemoveViews[id] = viewItem
delete this.views[id]
this.queue.queueJob({
id,
priority: this.getRenderPriority(viewItem.view),
cb: () => {
this.removeView(viewItem.view)
},
})
}
})
this.flush()
}
protected flush() {
this.graph.options.async
? this.queue.queueFlush()
: this.queue.queueFlushSync()
}
protected flushWaitingViews() {
Object.values(this.views).forEach((viewItem) => {
if (viewItem && viewItem.state === SchedulerViewState.WAITING) {
const { view, flag, options } = viewItem
this.requestViewUpdate(
view,
flag,
options,
this.getRenderPriority(view),
false,
)
}
})
this.flush()
}
protected updateView(view: View, flag: number, options: KeyValue = {}) {
if (view == null) {
return 0
}
if (CellView.isCellView(view)) {
if (flag & FLAG_REMOVE) {
this.removeView(view)
return 0
}
if (flag & FLAG_INSERT) {
this.insertView(view)
flag ^= FLAG_INSERT // eslint-disable-line
}
}
if (!flag) {
return 0
}
return view.confirmUpdate(flag, options)
}
protected insertView(view: CellView) {
const viewItem = this.views[view.cell.id]
if (viewItem) {
const zIndex = view.cell.getZIndex()
const pivot = this.addZPivot(zIndex)
this.container.insertBefore(view.container, pivot)
if (!view.cell.isVisible()) {
this.toggleVisible(view.cell, false)
}
viewItem.state = SchedulerViewState.MOUNTED
this.graph.trigger('view:mounted', { view })
}
}
protected resetViews() {
this.willRemoveViews = { ...this.views, ...this.willRemoveViews }
Object.values(this.willRemoveViews).forEach((viewItem) => {
if (viewItem) {
this.removeView(viewItem.view)
}
})
this.views = {}
this.willRemoveViews = {}
}
protected removeView(view: CellView) {
const cell = view.cell
const viewItem = this.willRemoveViews[cell.id]
if (viewItem && view) {
viewItem.view.remove()
delete this.willRemoveViews[cell.id]
this.graph.trigger('view:unmounted', { view })
}
}
protected toggleVisible(cell: Cell, visible: boolean) {
const edges = this.model.getConnectedEdges(cell)
for (let i = 0, len = edges.length; i < len; i += 1) {
const edge = edges[i]
if (visible) {
const source = edge.getSourceCell()
const target = edge.getTargetCell()
if (
(source && !source.isVisible()) ||
(target && !target.isVisible())
) {
continue
}
this.toggleVisible(edge, true)
} else {
this.toggleVisible(edge, false)
}
}
const viewItem = this.views[cell.id]
if (viewItem) {
Dom.css(viewItem.view.container, {
display: visible ? 'unset' : 'none',
})
}
}
protected addZPivot(zIndex = 0) {
if (this.zPivots == null) {
this.zPivots = {}
}
const pivots = this.zPivots
let pivot = pivots[zIndex]
if (pivot) {
return pivot
}
pivot = pivots[zIndex] = document.createComment(`z-index:${zIndex + 1}`)
let neighborZ = -Infinity
// eslint-disable-next-line
for (const key in pivots) {
const currentZ = +key
if (currentZ < zIndex && currentZ > neighborZ) {
neighborZ = currentZ
if (neighborZ === zIndex - 1) {
}
}
}
const layer = this.container
if (neighborZ !== -Infinity) {
const neighborPivot = pivots[neighborZ]
layer.insertBefore(pivot, neighborPivot.nextSibling)
} else {
layer.insertBefore(pivot, layer.firstChild)
}
return pivot
}
protected removeZPivots() {
if (this.zPivots) {
Object.values(this.zPivots).forEach((elem) => {
if (elem && elem.parentNode) {
elem.parentNode.removeChild(elem)
}
})
}
this.zPivots = {}
}
protected createCellView(cell: Cell) {
const options = { graph: this.graph }
const createViewHook = this.graph.options.createCellView
if (createViewHook) {
const ret = FunctionExt.call(createViewHook, this.graph, cell)
if (ret) {
return new ret(cell, options) // eslint-disable-line new-cap
}
if (ret === null) {
// null means not render
return null
}
}
const view = cell.view
if (view != null && typeof view === 'string') {
const def = CellView.registry.get(view)
if (def) {
return new def(cell, options) // eslint-disable-line new-cap
}
return CellView.registry.onNotFound(view)
}
if (cell.isNode()) {
return new NodeView(cell, options)
}
if (cell.isEdge()) {
return new EdgeView(cell, options)
}
return null
}
protected getEffectedEdges(view: CellView) {
const effectedEdges: { id: string; view: CellView; flag: number }[] = []
const cell = view.cell
const edges = this.model.getConnectedEdges(cell)
for (let i = 0, n = edges.length; i < n; i += 1) {
const edge = edges[i]
const viewItem = this.views[edge.id]
if (!viewItem) {
continue
}
const edgeView = viewItem.view
if (!this.isViewMounted(edgeView)) {
continue
}
const flagLabels: FlagManagerAction[] = ['update']
if (edge.getTargetCell() === cell) {
flagLabels.push('target')
}
if (edge.getSourceCell() === cell) {
flagLabels.push('source')
}
effectedEdges.push({
id: edge.id,
view: edgeView,
flag: edgeView.getFlag(flagLabels),
})
}
return effectedEdges
}
protected isUpdatable(view: CellView) {
if (view.isNodeView()) {
if (this.renderArea) {
return this.renderArea.isIntersectWithRect(view.cell.getBBox())
}
return true
}
if (view.isEdgeView()) {
const edge = view.cell
const intersects = this.renderArea
? this.renderArea.isIntersectWithRect(edge.getBBox())
: true
if (this.graph.virtualRender.isVirtualEnabled()) {
return intersects
}
const sourceCell = edge.getSourceCell()
const targetCell = edge.getTargetCell()
if (this.renderArea && sourceCell && targetCell) {
return (
this.renderArea.isIntersectWithRect(sourceCell.getBBox()) ||
this.renderArea.isIntersectWithRect(targetCell.getBBox())
)
}
}
return true
}
protected getRenderPriority(view: CellView) {
return view.cell.isNode()
? JOB_PRIORITY.RenderNode
: JOB_PRIORITY.RenderEdge
}
dispose() {
this.stopListening()
// clear views
Object.keys(this.views).forEach((id) => {
this.views[id].view.dispose()
})
this.views = {}
}
}