butterfly-dag
Version:
一个基于数据驱动的节点式编排组件库,让你有方便快捷定制可视化流程图表
534 lines (437 loc) • 14.6 kB
JavaScript
const _ = require('lodash');
const $ = require('jquery');
// 每一个dot是一个圆形或者
const DOT_COLOR = 'rgba(246, 105, 2, 1)';
const GROUP_COLOR = 'rgba(61, 86, 92, 1)';
const DOT_ACTIVE_COLOR = 'rgba(255, 253, 76, 1)';
const GROUP_ACTIVE_COLOR = 'rgba(255, 253, 76, 1)';
const SAFE_DISTANCE = 20;
// 修改一个element的css的属性
const modifyCSS = (ele, cssStyle) => {
if (!ele || !ele.style) {
return;
}
Object.keys(cssStyle).forEach(key => {
ele.style[key] = cssStyle[key];
});
};
// 获取Canvas缩放比,兼容高清屏
const getPixelRatio = function (context) {
const backingStore = context.backingStorePixelRatio ||
context.webkitBackingStorePixelRatio ||
context.mozBackingStorePixelRatio ||
context.msBackingStorePixelRatio ||
context.oBackingStorePixelRatio ||
context.backingStorePixelRatio || 1;
return (window.devicePixelRatio || 1) / backingStore;
};
// check constructor 的 options
const checkOpts = (options) => {
if(!options) {
throw new Error('options cant be empty');
}
if(!options.root || typeof options.root !== 'object' || !$(options.root)) {
throw new Error('options.root must be a html element');
}
if(!options.move || typeof options.move !== 'function') {
throw new Error('options.move must be a fuction');
}
if(!options.terminal2canvas || typeof options.terminal2canvas !== 'function') {
throw new Error('options.move must be a fuction');
}
}
/**
* options:
* height {Number} 缩略图高度 default 200,
* width {Number} 缩略图宽度 default 200,
* className {String} default: butterfly-minimap-container
* containerStyle {Object} 外层css
* viewportStyle {Object} 视口css
* backgroudStyle {Object} 底层css
* nodeColor {String} 节点颜色
* groupColor {String} 节点组颜色
* root {Element} 画布容器节点
* containerWidth {Number} 画布的宽度, 可自定义
* containerHeight {Number} 同上
* nodes {Pointer[]} 节点信息
* node.left, node.top 节点的坐标轴信息
* groups {Object[]} 节点组信息
* group.left, group.top, group.width, group.height 节点组的二维信息
* offset {Pointer} 偏移信息
* zoom {Number} 画布当前缩放比
* move {Function} 缩略图互动函数, 用于移动画布, 参考小蝴蝶的move
* terminal2canvas {Function} 互动函数, 屏幕坐标到画布坐标的转换
* safeDistance {Number} 画布视口在minimap距离边距的安全距离,默认20
* activeNodeColor {String} 选中的节点颜色
* activeGroupColor {String} 选中的节点组颜色
* events {String[]} 补充的监听事件
*/
class Minimap {
constructor(options) {
checkOpts(options);
this.root = options.root;
this.$root = $(this.root);
this.options = {
height: 200,
width: 200,
className: 'butterfly-minimap-container',
containerStyle: {},
viewportStyle: {},
backgroudStyle: {},
nodeColor: DOT_COLOR,
groupColor: GROUP_COLOR,
activeNodeColor: DOT_ACTIVE_COLOR,
activeGroupColor: GROUP_ACTIVE_COLOR,
containerWidth: $(this.root).width(),
containerHeight: $(this.root).height(),
nodes: [],
groups: [],
offset: [0, 0],
zoom: 1,
move: () => null,
terminal2canvas: () => null,
safeDistance: SAFE_DISTANCE,
...options
};
// 画布到缩略图的缩放比
this.ratio = 1;
// 初始化容器
this.initContainer();
// 渲染视口
this.renderViewPort();
// 渲染背景
this.renderBG();
}
// 获取所有元素的屏幕坐标
getItemsPoint = () => {
const nodes = _.cloneDeep(this.options.nodes);
const groups = _.cloneDeep(this.options.groups);
const {canvas2terminal} = this.options;
const height = this.$root.height();
const width = this.$root.width();
// 增加两个虚拟的点,防止只有一个node时宽度过大的问题
nodes.push({left: 0, top: 0, height: 1, width: 1});
nodes.push({left: width, top: height, height: 1, width: 1});
// 计算所有 nodes 的真实坐标
for(let node of nodes) {
if(node.group) {
const group = _.find(groups, {id: node.group});
if(!group) {
continue;
}
node.rleft = group.left + node.left;
node.rtop = group.top + node.top;
continue;
}
node.rleft = node.left;
node.rtop = node.top;
}
groups.forEach(group => {
const leftTop = [group.left, group.top];
const rightBottom = [group.left + group.width, group.top + group.height];
const screenLeftTop = canvas2terminal(leftTop);
const screenRightBottom = canvas2terminal(rightBottom);
group.screenLeftTop = screenLeftTop;
group.screenRightBottom = screenRightBottom;
group.left = screenLeftTop[0];
group.top = screenLeftTop[1];
group.width = screenRightBottom[0] - screenLeftTop[0];
group.height = screenRightBottom[1] - screenLeftTop[1];
});
nodes.forEach(node => {
const leftTop = [node.rleft, node.rtop];
const rightBottom = [node.rleft + node.width, node.rtop + node.height];
const screenLeftTop = canvas2terminal(leftTop);
const screenRightBottom = canvas2terminal(rightBottom);
node.left = screenLeftTop[0];
node.top = screenLeftTop[1];
node.width = screenRightBottom[0] - screenLeftTop[0];
node.height = screenRightBottom[1] - screenLeftTop[1];
});
return {
groups, nodes
}
}
// 获取画布的有内容的区域
getBBox() {
const {nodes, groups} = this.getItemsPoint()
const check = (v) => {
return _.isNumber(v) ? v : 0;
}
const allNTop = nodes.map(node => node.top);
const allNLeft = nodes.map(node => node.left);
const allNBottom = nodes.map(node => node.top + node.height);
const allNRight = nodes.map(node => node.left + node.width);
const allGTop = groups.map(group => group.top);
const allGLeft = groups.map(group => group.left);
const allGBottom = groups.map(group => group.top + group.height);
const allGRight = groups.map(group => group.left + group.width);
const allTop = allNTop.concat(allGTop);
const allLeft = allNLeft.concat(allGLeft);
const allBottom = allNBottom.concat(allGBottom);
const allRight = allNRight.concat(allGRight);
const minX = check(_.min(allLeft));
const minY = check(_.min(allTop));
const maxX = check(_.max(allRight));
const maxY = check(_.max(allBottom));
return {
minX: minX,
minY: minY,
width: maxX - minX,
height: maxY - minY,
}
}
// 获取画布和缩略图的缩放比
setRatio() {
const height = this.options.height;
const width = this.options.width;
const graphSize = this.getBBox();
if(graphSize.width === 0 || graphSize.height === 0) {
return 0;
}
const ratio = Math.min(width / graphSize.width, height / graphSize.height);
this.ratio = Number(ratio.toFixed(2));
}
// 更新小地图数据
update({
nodes = [],
groups = [],
zoom = 1,
offset = [0, 0],
}) {
this.options.nodes = nodes;
this.options.groups = groups;
this.options.zoom = zoom;
this.options.offset = offset;
this.debounceRender();
}
getViewportBBox = () => {
const $viewport = $(this.viewportDOM);
const parent = $viewport.offsetParent();
const offset = $viewport.offset();
const poffset = $(parent).offset();
const left = offset.left - poffset.left;
const top = offset.top - poffset.top;
const width = $viewport.width();
const height = $viewport.height();
const right = left + width;
const bottom = top + height;
return {
left, top, right,bottom, height, width
}
}
// 初始化画布
initContainer() {
const {
height, width, className,
viewportStyle, backgroudStyle, containerStyle
} = this.options;
this.container = document.createElement('div');
this.viewportDOM = document.createElement('div');
this.backgroundDOM = document.createElement('div');
this.container.setAttribute('class', className);
const initStyle = {
position: 'absolute',
left: 0,
top: 0,
overflow: 'hidden',
height: height + 'px',
width: width + 'px'
};
modifyCSS(this.container, {
...initStyle,
right: '10px',
bottom: '10px',
left: 'none',
top: 'none',
height: height + 'px',
width: width + 'px',
border: '1px solid #aaa',
'z-index': 100,
...containerStyle
});
modifyCSS(this.viewportDOM , {
...initStyle,
left: 0,
top: 0,
// border: '1px solid pink',
'background-color': 'rgba(246,105,2,0.20)',
...viewportStyle,
});
modifyCSS(this.backgroundDOM , {
...initStyle,
...backgroudStyle,
height: height + 'px',
width: width + 'px',
});
this.root.appendChild(this.container);
this.container.appendChild(this.backgroundDOM);
this.container.appendChild(this.viewportDOM);
this.initBGCanvas();
this.initViewportEvts();
}
// 创建BG canvas
initBGCanvas() {
const {width, height} = this.options;
const canvasDom = document.createElement('canvas');
this.backgroundDOM.appendChild(canvasDom);
canvasDom.setAttribute('width', width);
canvasDom.setAttribute('height', height);
modifyCSS(canvasDom, {
position: 'absolute',
width: '100%',
height: '100%',
left: 0,
top: 0
});
const cvsCtx = canvasDom.getContext('2d');
// 初始化2D画布
this.cvsCtx = cvsCtx;
this.cvsRatio = getPixelRatio(cvsCtx);
cvsCtx.scale(this.cvsRatio, this.cvsRatio);
}
// 初始化视窗事件
initViewportEvts() {
let dragging = false;
let x = 0;
let y = 0;
let left = 0;
let top = 0;
this.viewportEvents = {
mousedown: (e) => {
e.preventDefault();
e.stopPropagation();
const viewportDOM = this.viewportDOM;
if (e.target !== this.viewportDOM) {
return;
}
const viewportBBox = this.getViewportBBox();
left = parseInt(viewportBBox.left, 10);
top = parseInt(viewportBBox.top, 10);
dragging = true;
x = e.clientX;
y = e.clientY;
},
mousemove: (e) => {
e.preventDefault();
e.stopPropagation();
if (!dragging || _.isNil(e.clientX) || _.isNil(e.clientY)) {
return;
}
let dx = x - e.clientX;
let dy = y - e.clientY;
left -= dx;
top -= dy;
const {width: mapWidth, height: mapHeight, safeDistance} = this.options;
const {width: vpWidth, height: vpHeight} = this.getViewportBBox();
// 限制视口不能够超出 minimap 的安全距离
if(
left >= mapWidth - safeDistance ||
top >= mapHeight - safeDistance ||
left + vpWidth <= safeDistance ||
top + vpHeight <= safeDistance
) {
left += dx;
top += dy;
return;
}
modifyCSS(this.viewportDOM, {
left: left + 'px',
top: top + 'px'
});
const {minX, minY} = this.getBBox();
const offset = this.$root.offset();
const boffset = this.options.offset;
const ddx = (((-left / this.ratio) + offset.left) - minX) * this.ratio;
const ddy = (((-top / this.ratio) + offset.top) - minY) * this.ratio;
this.options.move(
[
boffset[0] + ddx / this.ratio,
boffset[1] + ddy / this.ratio
]
);
x = e.clientX;
y = e.clientY;
},
mouseleave: () => {
dragging = false;
},
mouseup: () => {
dragging = false;
}
};
Object.keys(this.viewportEvents).forEach(key => {
this.container.addEventListener(key, this.viewportEvents[key]);
});
}
// 渲染缩略图canvas
renderBG() {
const cvsCtx = this.cvsCtx;
const {width, height, nodeColor, groupColor, activeNodeColor, activeGroupColor} = this.options;
const cvsRatio = this.cvsRatio;
cvsCtx.clearRect(0, 0, width, height);
// 根据所有点的信息画出所有的点
const {nodes, groups} = this.getItemsPoint();
const {minX, minY} = this.getBBox();
groups.forEach(group => {
const left = (group.left - minX) * this.ratio;
const top = (group.top - minY) * this.ratio;
const width = group.width * this.ratio;
const height = group.height * this.ratio;
const minimapActive = group.minimapActive;
if(minimapActive) {
cvsCtx.fillStyle = activeGroupColor;
} else {
cvsCtx.fillStyle = groupColor;
}
cvsCtx.fillRect(left / cvsRatio, top / cvsRatio, width / cvsRatio, height / cvsRatio);
});
nodes.forEach(node => {
const left = (node.left - minX) * this.ratio;
const top = (node.top - minY) * this.ratio;
const width = node.width * this.ratio;
const height = node.height * this.ratio;
if(node.minimapActive) {
cvsCtx.fillStyle = activeNodeColor;
} else {
cvsCtx.fillStyle = nodeColor;
}
// cvsCtx.beginPath();
cvsCtx.fillRect(left / cvsRatio, top / cvsRatio, width / cvsRatio, height / cvsRatio);
// cvsCtx.arc(left / cvsRatio, top / cvsRatio, DOT_SIZE / cvsRatio, 0, 2*Math.PI);
// cvsCtx.closePath();
// cvsCtx.fill();
});
}
// 绘制出拖动框
renderViewPort() {
this.setRatio();
const {minX, minY} = this.getBBox();
const {top, left} = this.$root.offset();
const rootWidth = this.$root.width();
const rootHeight = this.$root.height();
const offset = [left - minX, top - minY];
// 获取画布到minimap的缩放比
const ratio = this.ratio;
const vwidth = rootWidth * this.ratio;
const vheight = rootHeight * this.ratio;
const vleft = Math.round(Math.round(offset[0]) * ratio);
const vtop = Math.round(Math.round(offset[1]) * ratio);
modifyCSS(this.viewportDOM, {
width: `${vwidth}px`,
height: `${vheight}px`,
left: `${vleft}px`,
top: `${vtop}px`,
});
}
// 延时渲染
debounceRender = _.debounce(() => {
this.renderViewPort();
this.renderBG();
}, 100)
destroy() {
// 销毁DOM
this.root.removeChild(this.container);
}
}
export default Minimap;