Source: cyez.js

import cytoscape from 'cytoscape'
import cxtmenu from '@lsvih/cytoscape-cxtmenu'
import navigator from 'cytoscape-navigator'
import RegisterEdgeHandle from './cyez-edgehandle'
import Layout from './cyez-layout'
import {saveAs} from 'file-saver'
import fileDialog from 'file-dialog'
import panzoom from 'cytoscape-panzoom'
import b64toBlob from 'b64-to-blob'
import {assign, concat, isEmpty, merge} from 'lodash'

import 'cytoscape-panzoom/cytoscape.js-panzoom.css'

class Cyez {
    /**
     * 传入交互组件的容器,以及样式,构建 cyez 实例
     * @param container {Node} 交互组件所在的容器,用 document.getElementById 等选择器得到
     * @param style {Object} 交互组件的样式模板
     * @param options {?Object} cytoscape 的设置
     */
    constructor(container, style, options) {
        console.log('Bind cytoscape to DOM ', container)
        let default_cy_options = {
            minZoom: 1e-50,
            maxZoom: 1e50,
            zoomingEnabled: true,
            userZoomingEnabled: true,
            panningEnabled: true,
            userPanningEnabled: true,
            boxSelectionEnabled: true,
            selectionType: 'single',
            touchTapThreshold: 8,
            desktopTapThreshold: 4,
            autolock: false,
            autoungrabify: false,
            autounselectify: false,
            // rendering options:
            headless: false,
            styleEnabled: true,
            hideEdgesOnViewport: true,
            hideLabelsOnViewport: true,
            textureOnViewport: false,
            motionBlur: true,
            motionBlurOpacity: 0.2,
            wheelSensitivity: .6,
            pixelRatio: 1
        }
        let cy_options = merge({}, default_cy_options, options)
        this.cy = cytoscape({
            container,
            style,
            ...cy_options
        })
        console.log('Init cyez', this.cy)
        this.layout = new Layout()
        /**
         * 判断当前画布是否应该冻结
         * @var
         * @type {boolean}
         */
        this.freeze = false
        this.current_layout = null
        this.edge_handle = null
        /**
         * 用于存储右键菜单
         * @private
         * @member Cyez
         * @type {{}}
         */
        this.contextmenu = {}
        /**
         * 定义了多种点击事件
         * @interface
         */
        this.event = {}
        this.init()
        this.initEvent()
    }

    /**
     * 初始化各组件与事件
     * @private
     */
    init() {
        this.RegisterContextMenu()
        this.RegisterNavigator()
        this.RegisterPanzoom()
        RegisterEdgeHandle(cytoscape, this)
        this.RegisterDoubleClickEvent()
        this.RegisterGestures()
        this.RegisterLayout()
    }


    /**
     * 返回画布的中心点
     * @returns {{x: number, y: number}}
     * @public
     */
    getCenter() {
        return {x: this.cy.width() / 2, y: this.cy.height() / 2}
    }


    /**
     * Initial layout algorithms
     * @private
     */
    RegisterLayout() {
        this.layout.RegisterLayout(cytoscape)
    }

    /**
     * 注册鹰眼导航
     * @private
     */
    RegisterNavigator() {
        if (typeof cytoscape('core', 'navigator') !== 'function')
            navigator(cytoscape)
    }

    /**
     * 注册放大缩小 UI 组件
     * @private
     */
    RegisterPanzoom() {
        if (typeof cytoscape('core', 'panzoom') !== 'function')
            panzoom(cytoscape)
        this.cy.panzoom({
            zoomFactor: 0.1,
            zoomDelay: 45,
            minZoom: 0.1,
            maxZoom: 10,
            fitPadding: 50,
            panSpeed: 10,
            panDistance: 100,
            panDragAreaSize: 75,
            panMinPercentSpeed: 0.25,
            panInactiveArea: 8,
            panIndicatorMinOpacity: 0.5,
            zoomOnly: false,
            fitSelector: undefined,
            animateOnFit: function () {
                return false
            },
            fitAnimationDuration: 1000
        })
    }


    /**
     * 初始化导航组件
     * @param {String} 传入指定的 container selector,如'.cytoscape-navigator'
     * @public
     */
    InitNavigator(container) {
        this.cy.navigator({
            container: container,
            viewLiveFramerate: 0,
            thumbnailEventFramerate: 30,
            thumbnailLiveFramerate: false,
            dblClickDelay: 200,
            removeCustomContainer: true,
            rerenderDelay: 100
        })
    }

    /**
     * 在画布上注册双击时间 doubleTap
     * @param doubleClickDelayMs {Number} 定义双击时长
     * @private
     */
    RegisterDoubleClickEvent(doubleClickDelayMs = 350) {
        let previousTapStamp
        this.cy.on('tap', e => {
            let currentTapStamp = e.timeStamp
            let msFromLastTap = currentTapStamp - previousTapStamp
            if (msFromLastTap < doubleClickDelayMs) {
                e.target.trigger('doubleTap', e)
            }
            previousTapStamp = currentTapStamp
        })
    }

    /**
     * 注册右键菜单组件
     * @private
     */
    RegisterContextMenu() {
        if (typeof cytoscape('core', 'cxtmenu') !== 'function')
            cytoscape.use(cxtmenu)
    }

    /**
     * 注册各种点击事件
     * @private
     */
    RegisterGestures() {
        this.cy.on('doubleTap', e => {
            let target = e.target
            if (e.target === this.cy)// double click on background
                return this.event.dbclickOnBackground(e)
            if (target.isNode())
                return this.event.dbclickOnNode(target.data(), e)
            if (target.isEdge())
                return this.event.dbclickOnEdge(target.data(), e)
        })

        this.cy.on('tap', e => {
            let target = e.target
            if (e.target === this.cy)// click on background
                return this.event.clickOnBackground(e)
            if (target.isNode())
                return this.event.clickOnNode(target.data(), e)
            if (target.isEdge())
                return this.event.clickOnEdge(target.data(), e)
        })

        this.cy.on('cxttap', e => {
            let target = e.target
            if (e.target === this.cy)// click on background
                return this.event.rightclickOnBackground(e)
            if (target.isNode())
                return this.event.rightclickOnNode(target.data(), e)
            if (target.isEdge())
                return this.event.rightclickOnEdge(target.data(), e)
        })
    }


    /**
     * 增加一个新的节点
     * @param id {(Number | String)} 节点的 id
     * @param name {String} 节点的名称
     * @param type {String} 节点的类型
     * @param attr {Object} 节点的属性
     * @return {cy.node} 得到的节点实例
     * @public
     */
    addNode(id, name, type, attr) {
        let added_node = this.cy.add({
            group: 'nodes',
            data: {id, name, type, attr},
            position: this.getCenter()
        })
        return this.getNodeById(added_node.id())
    }

    /**
     * 增加一系列节点
     * @param node_list {cy.node[]} 传入形式为:[{name, id, type, attr}]
     * @returns {cy.node}
     */
    addNodes(node_list, options) {
        let default_options = {
            position: this.getCenter()
        }
        options = assign(default_options, options)
        return this.cy.add(node_list.map(node => {
            return {
                group: 'nodes',
                data: node,
                position: {x: options.position.x + Math.random() * 10, y: options.position.y + Math.random() * 10,}
            }
        }))
    }


    /**
     * 增加一系列节点
     * @param edge_list {cy.edge[]} 传入形式为:[{id, name, source, target}]
     * @returns {cy.edge}
     */
    addEdges(edge_list) {
        return this.cy.add(edge_list.map(edge => {
            return {
                group: 'edges',
                data: edge
            }
        }))
    }

    /**
     * 在 id 为 source_id 和 id 为 target_id 的两个节点间增加关系为 name 的边
     * @param source_id {(Number | String)} 源节点的 id
     * @param target_id {(Number | String)} 目标节点的 id
     * @param name {?String} 连边的名字
     * @public
     */
    addEdge(source_id, target_id, name) {
        this.cy.add({
            group: 'edges',
            data: {
                id: `${source_id}to${target_id}`, source: source_id, target: target_id, name
            }
        })
    }

    /**
     * 删除指定的连边
     * @param edge {cytoscape.edge} 待删除的连边
     * @public
     */
    DeleteEdge(edge) {
        edge.remove()
    }

    /**
     * 根据 id 删除指定的连边
     * @param id {!(String | Number)} 需要删除的边的 id
     * @public
     */
    DeleteEdgeById(id) {
        let edge = this.getEdgeById(id)
        if (edge !== null) {
            this.DeleteEdge(edge)
        }
    }

    /**
     * 根据 id 获取边的实例
     * @param id {!(String | Number)} 需要查询的边的 id
     * @param silence {boolean} 是否在控制栏显示错误
     * @return {?cytoscape.edge} 查询得到的边,如果没有找到对应 id 的边则返回 null
     * @public
     */
    getEdgeById(id, silence = false) {
        let edge = this.cy.edges(`edge#${id}`)
        if (edge.length === 0) {
            if (!silence)
                console.warn(`没有找到 id 为 ${id} 的连边`)
            return null
        } else {
            return edge
        }
    }

    /**
     * 获取当前被选中的元素(包括节点和连边)
     * @return {?cytoscape.element} 被选中的元素的实例
     * @public
     */
    getSelectedElements() {
        return this.cy.$(':selected')
    }

    /**
     * 获取当前被选中的节点
     * @return {?cytoscape.node} 被选中的节点的实例
     * @public
     */
    getSelectedNodes() {
        return this.cy.$('node:selected')
    }

    /**
     * 获取当前被选中的连边
     * @return {?cytoscape.edge} 被选中的连边的实例
     * @public
     */
    getSelectedEdges() {
        return this.cy.$('edge:selected')
    }

    /**
     * 根据节点 id 获取其邻居节点
     * @param id {!(String | Number)} 需要查找邻居的节点 id
     * @return {?cy.node[]} 邻居节点集合,如果没有找到则返回空数组
     * @public
     */
    getNeighborById(id) {
        let node = this.getNodeById(id)
        if (!node) {
            console.warn(`没有找到id为${id}的节点`)
            return null
        } else {
            return node.neighborhood()
        }
    }


    /**
     * 根据节点 id 获取节点实体
     * @param id
     * @return {?cy.node}
     * @public
     */
    getNodeById(id) {
        let selector = this.cy.$(`node#${id}`)
        if (selector.length === 1)
            return selector[0]
        else if (selector.length > 1) {
            console.warn(`存在多个id为${id}的节点`)
            return selector[0]
        } else
            return null
    }

    /**
     * 反选,即取消当前选中的元素,选中当前未选中的元素
     * @public
     */
    inverseSelection() {
        let un_selected_elems = this.getUnselectedElements()
        this.getSelectedElements().unselect()
        un_selected_elems.select()
    }

    /**
     * 获取当前未被选中的元素(包括节点和连边)
     * @return {?cytoscape.element} 被选中的元素的实例
     * @public
     */
    getUnselectedElements() {
        return this.cy.$(':unselected')
    }


    /**
     * 判断 id 是否已经存在
     * @param id {!(String | Number)} 需要判断的 id
     * @returns {boolean}
     * @public
     */
    isIdExist(id) {
        return !isEmpty(this.getNodeById(id)) && !isEmpty(this.getEdgeById(id, true))
    }


    /**
     * 根据节点 id 获取节点坐标
     * @param id
     * @returns {Object}
     */
    getPositionById(id) {
        let node = this.getNodeById(id)
        if (!node) {
            console.warn(`没有找到id为${id}的节点`)
            return null
        } else {
            return node.position()
        }
    }


    /**
     * 将布局算法应用与全部节点。不能在一次布局完成前进行第二次布局。
     * @param layout {String} 指定一种布局方式,可选值有:['grid', 'circle', 'concentric', 'breadthfirst', 'cose', 'cola', 'dagre', 'klay', 'spread']
     * @param callback {?Function} 可选,布局完成之后将调用此函数
     * @public
     */
    Layout(layout, callback) {
        if (this.freeze) {
            console.info('当前画布处于冻结状态')
        } else {
            try {
                this.freeze = true // 防止重复布局,冻结画布
                this.current_layout = this.cy.layout({
                    name: layout,
                    animate: true,
                    maxSimulationTime: 4000,
                    animationDuration: 1000,
                    animationEasing: 'ease-in-out',
                    stop: () => {
                        this.freeze = false // 布局完成后解冻画布
                        this.current_layout = null
                        if (callback != null) {
                            callback()
                        }
                    }
                })
                this.current_layout.run()
            } catch (e) {
                this.freeze = false
                this.current_layout = null
                console.warn(`进行 ${layout} 布局时发生错误`)
                console.error(e)
            }
        }
    }


    /**
     * 对部分节点应用布局算法
     * @param nodes {!cytoscape.node} 传入需要进行布局的节点
     * @param options {!object} 选项,{layout:'grid', position:{x:0,y:0}}。layout指定一种布局方式,可选值参见 {@link Layout},position指定布局中心
     * @param callback {?Function} 可选,布局完成后将调用此函数
     * @public
     */
    LayoutNodes(nodes, options, callback) {
        let default_options = {
            layout: 'grid',
            position: this.getCenter(),
            radius: 1
        }
        let new_options = merge({}, default_options, options)
        new_options.position = {
            x1: new_options.position.x - new_options.radius,
            x2: new_options.position.x + new_options.radius,
            y1: new_options.position.y - new_options.radius,
            y2: new_options.position.y + new_options.radius
        }
        new_options = merge({}, new_options, options)
        if (this.freeze) {
            console.info('当前画布处于冻结状态')
        } else {
            try {
                this.freeze = true
                this.current_layout = nodes.makeLayout({
                    name: new_options.layout,
                    fit: false,
                    boundingBox: new_options.position,
                    animate: true,
                    maxSimulationTime: 4000,
                    animationDuration: 1000,
                    animationEasing: 'ease-in-out',
                    avoidOverlap: true,
                    stop: () => {
                        this.freeze = false // 布局完成后解冻画布
                        this.current_layout = null
                        if (callback != null) {
                            callback()
                        }
                    }
                })
                this.current_layout.run()
            } catch (e) {
                this.freeze = false
                this.current_layout = null
                console.warn(`进行 ${new_options.layout} 布局时发生错误`)
                console.error(e)
            }
        }
    }

    /**
     * 停止正在进行的布局
     * @public
     * @return Boolean
     */
    stopLayout() {
        if (isEmpty(this.current_layout)) {
            return false
        } else {
            this.current_layout.stop()
            this.current_layout = null
            return true
        }
    }

    /**
     * 将布局锁定
     * @public
     */
    lock() {
        this.cy.elements().lock()
    }

    /**
     * 解锁布局
     * @public
     */
    unlock() {
        this.cy.elements().unlock()
    }

    /**
     * 将数组数据聚合为集合
     * @param elements {(cytoscape.node[] | cytoscape.edge[] | cytoscape.ele[])} 以数组形式传入的实例
     * @returns {cytoscape.eles} 以集合传出的实例
     * @public
     */
    Collection(elements) {
        return this.cy.collection().add(elements)
    }


    /**
     * 高亮设定的节点。相当于将除了传入节点之外的其它节点和连线加上 faded class,因此需要在样式中加上 .faded{opacity:0.1} 的设定
     * @param nodes {!cytoscape.node} 需要高亮的节点
     * @public
     */
    HighlightNodes(nodes) {
        this.HighlightElements(nodes)
    }

    /**
     * 高亮设定的连边。
     * @param edges {!cytoscape.edge} 需要高亮的连边
     * @public
     */
    HighlightEdges(edges) {
        this.HighlightElements(edges)
    }

    /**
     * 高亮设定的元素(包括节点和连边)
     * @param elem {!cytoscape.element} 需要高亮的元素
     * @public
     */
    HighlightElements(elems) {
        this.cy.batch(() => elems.addClass('highlighted'))
    }

    /**
     * 对设定的节点取消高亮。相当于将除了传入节点之外的其它节点和连线去掉 faded class
     * @param nodes {!cytoscape.node} 需要高亮的节点
     * @public
     */
    CancelHighlightNodes(nodes) {
        this.CancelHighlightElements(nodes)
    }

    /**
     * 对设定的元素取消高亮。相当于将除了传入节点之外的其它节点和连线去掉 faded class
     * @param nodes {!cytoscape.element} 需要高亮的节点
     * @public
     */
    CancelHighlightElements(elems) {
        this.cy.batch(() => elems.removeClass('highlighted'))
    }

    /**
     * 取消高亮节点,即将全部的节点的 faded class 都去掉
     * @public
     */
    CancelHighlight() {
        let all_elements = this.cy.elements()
        this.cy.batch(() => all_elements.removeClass('highlighted'))
    }


    /**
     * 获取所有节点的信息
     * @returns {nodes}
     */
    get nodes() {
        return this.cy.nodes().map(e => e.data())
    }

    /**
     * 获取所有连边的信息
     * @returns {edges}
     */
    get edges() {
        return this.cy.edges().map(e => e.data())
    }

    /**
     * 新增一个右键菜单
     * @param id {int|string} 右键菜单的标识,可以用此标识销毁对应菜单
     * @param option 参考 cytoscape contextmenu 文档
     */
    addContextMenu(id, option) {
        if (id in this.contextmenu) {
            console.error(`id 为${id}的右键菜单已经存在`)
            return false
        }
        this.contextmenu[id] = this.cy.cxtmenu(option)
    }

    /**
     * 将当前图谱导出为 json 文件,并下载。
     * @public
     */
    save() {
        let blob = new Blob([JSON.stringify(this.cy.json())], {type: 'text/plain;charset=utf-8'})
        saveAs(blob, 'graph.json')
    }

    /**
     * 从文件读取 json 文件,并加载到视图中
     * @public
     * @param callback {?Function} 可选,布局完成之后将调用此函数
     */
    load(callback) {
        fileDialog({accept: 'application/json'})
            .then(files => {
                let reader = new FileReader()
                reader.onload = file => {
                    let load = JSON.parse(file.target.result)
                    this.cy.json(load)
                    if (callback != null) {
                        callback(load)
                    }
                }
                reader.readAsText(files[0])
            })
    }

    /**
     * 将当前图谱导出为 jpg 图片,并下载
     * @public
     * @see https://github.com/iVis-at-Bilkent/pathway-mapper/blob/master/public/src/js/FileOperationsManager.js#L33
     */
    saveAsJPEG() {
        let graphData = this.cy.jpeg()
        let b64data = graphData.substr(graphData.indexOf(',') + 1)
        let imageData = b64toBlob(b64data, 'image/jpeg')
        let blob = new Blob([imageData])
        saveAs(blob, 'graph.jpg')
    }

    /**
     * 将当前图谱导出为 png 图片,并下载
     * @public
     * @see https://github.com/iVis-at-Bilkent/pathway-mapper/blob/master/public/src/js/FileOperationsManager.js#L44
     */
    saveAsPNG() {
        let graphData = this.cy.png()
        let b64data = graphData.substr(graphData.indexOf(',') + 1)
        let imageData = b64toBlob(b64data, 'image/png')
        let blob = new Blob([imageData])
        saveAs(blob, 'graph.png')
    }

    /**
     * 销毁指定 id 的右键菜单
     * @param id {!(String | Number)} 待删除的右键菜单的 id
     *
     */
    removeContextMenu(id) {
        if (!id in this.contextmenu) {
            console.error(`id 为${id}的右键菜单不存在`)
            return false
        }
        this.contextmenu[id].destroy()
        delete this.contextmenu[id]
    }

    /**
     * 将画布完全重置
     */
    reset() {
        this.cy.elements().remove()
        this.cy.reset()
    }

    /**
     * 缩放视图至指定的元素
     * @param eles {cy.nodes} 需要查看的元素
     * @see https://github.com/iVis-at-Bilkent/cytoscape.js-view-utilities/blob/master/src/view-utilities.js#L190
     * @returns {cy.nodes}
     * @public
     */
    zoomToSelected(eles) {
        let boundingBox = eles.boundingBox()
        let diff_x = Math.abs(boundingBox.x1 - boundingBox.x2)
        let diff_y = Math.abs(boundingBox.y1 - boundingBox.y2)
        let padding
        if (diff_x >= 200 || diff_y >= 200) {
            padding = 50
        } else {
            padding = (this.cy.width() < this.cy.height()) ?
                ((200 - diff_x) / 2 * this.cy.width() / 200) : ((200 - diff_y) / 2 * this.cy.height() / 200)
        }

        this.cy.animate({
            fit: {
                eles: eles,
                padding: padding
            }
        }, {
            duration: 1200
        })
        return eles
    }

    /**
     * 更新新的样式
     * @param new_style {JSON} 以JSON形式传入的新的样式
     */
    updateStyleFromJSON(new_style) {
        let old_style = this.cy.style().json()
        let style = concat(old_style, new_style)
        this.cy.style().fromJson(style).update()
    }


    /**
     * 隐藏指定的元素,即为selector的元素添加 display: 'none' 的样式属性
     * @param selector
     * @public
     */
    hideNodes(selector) {
        let hidden_style = [{
            selector,
            style: {
                display: 'none'
            }
        }]
        this.updateStyleFromJSON(hidden_style)
    }

    /**
     * 显示指定的元素,即为selector的元素删除 visibility: hide 的样式属性
     * @param selector
     * @public
     */
    showNodes(selector) {
        let old_style = this.cy.style().json()
        let style = old_style.filter(e => !(e.selector === selector && e.style.display === 'none'))
        this.cy.style().fromJson(style).update()
    }

    /**
     * 选择器
     * @param selector {String} cytoscape选择器
     * @returns {*|E.fn.init}
     */
    $(selector) {
        return this.cy.$(selector)
    }


    /**
     * 初始化事件
     * @private
     */
    initEvent() {
        this.event = {
            /**
             * 在节点上单击
             * @method
             * @param {string} node - 传出点击的节点
             * @param {Event} e - 点击事件
             */
            clickOnNode: (node, e) => {
            },
            /**
             * 在连线上单击
             * @method
             * @param {string} edge - 传出点击的连线
             * @param {Event} e - 点击事件
             */
            clickOnEdge: (edge, e) => {
            },
            /**
             * 在背景上单击
             * @method
             * @param {Event} e - 点击事件
             */
            clickOnBackground: e => {
            },
            /**
             * 在节点上双击
             * @method
             * @param {string} node - 传出点击的节点
             * @param {Event} e - 点击事件
             */
            dbclickOnNode: (node, e) => {
            },
            /**
             * 在连线上双击
             * @method
             * @param {string} edge - 传出点击的连线
             * @param {Event} e - 点击事件
             */
            dbclickOnEdge: (edge, e) => {
            },
            /**
             * 在背景上双击
             * @method
             * @param {Event} e - 点击事件
             */
            dbclickOnBackground: e => {
            },
            /**
             * 在节点上右击
             * @method
             * @param {string} node - 传出点击的节点
             * @param {Event} e - 点击事件
             */
            rightclickOnNode: (node, e) => {
            },
            /**
             * 在连线上右击
             * @method
             * @param {string} edge - 传出点击的连线
             * @param {Event} e - 点击事件
             */
            rightclickOnEdge: (edge, e) => {
            },
            /**
             * 在背景上右击
             * @method
             * @param {Event} e - 点击事件
             */
            rightclickOnBackground: e => {
            },
        }
    }

    /**
     * 判断指定 elements 中是否包含环
     * @param elements
     * @return {Boolean}
     */
    hasLoop(elements) {
        let clearClass = () =>
            this.cy.batch(() => {
                elements.removeClass('visited')
            })
        let nodes = elements.nodes()
        let flag = false
        for (let start_node of nodes.toArray()) {
            if (flag)
                break
            elements.dfs({
                root: `#${start_node.id()}`,
                directed: true,
                visit: (v, e, u, i, depth) => {
                    v.addClass('visited')
                    for (let child of v.outgoers().toArray())
                        if (child.hasClass('visited')) {
                            flag = true
                            return false
                        }
                }
            })
            clearClass()
        }
        clearClass()
        return flag
    }


}

export default Cyez