diff --git a/src/chart/graph/GraphSeries.ts b/src/chart/graph/GraphSeries.ts index e1b85bead6..5d69a30f3e 100644 --- a/src/chart/graph/GraphSeries.ts +++ b/src/chart/graph/GraphSeries.ts @@ -43,7 +43,8 @@ import { GraphEdgeItemObject, OptionDataValueNumeric, CallbackDataParams, - DefaultEmphasisFocus + DefaultEmphasisFocus, + ZRColor } from '../../util/types'; import SeriesModel from '../../model/Series'; import Graph from '../../data/Graph'; @@ -67,7 +68,6 @@ export interface GraphNodeStateOption { label?: SeriesLabelOption } - interface ExtraEmphasisState { focus?: DefaultEmphasisFocus | 'adjacency' } @@ -229,6 +229,14 @@ export interface GraphSeriesOption * auto curveness for multiple edge, invalid when `lineStyle.curveness` is set */ autoCurveness?: boolean | number | number[] + + thumbnail?: BoxLayoutOptionMixin & { + show?: boolean, + + itemStyle?: ItemStyleOption + + selectedAreaStyle?: ItemStyleOption + } } class GraphSeriesModel extends SeriesModel { @@ -509,6 +517,28 @@ class GraphSeriesModel extends SeriesModel { itemStyle: { borderColor: '#212121' } + }, + + thumbnail: { + show: false, + + right: 0, + bottom: 0, + + height: '25%', + width: '25%', + + itemStyle: { + color: 'white', + borderColor: 'black' + }, + + selectedAreaStyle: { + color: 'white', + borderColor: 'black', + borderWidth: 1, + opacity: 0.5 + } } }; } diff --git a/src/chart/graph/GraphView.ts b/src/chart/graph/GraphView.ts index 0c6d1e01bc..329cfe5cf5 100644 --- a/src/chart/graph/GraphView.ts +++ b/src/chart/graph/GraphView.ts @@ -35,6 +35,7 @@ import Symbol from '../helper/Symbol'; import SeriesData from '../../data/SeriesData'; import Line from '../helper/Line'; import { getECData } from '../../util/innerStore'; +import Thumbnail from './Thumbnail'; import { simpleLayoutEdge } from './simpleLayoutHelper'; import { circularLayout, rotateNodeLabel } from './circularLayoutHelper'; @@ -62,45 +63,50 @@ class GraphView extends ChartView { private _layouting: boolean; + private _thumbnail: Thumbnail; + + private _mainGroup: graphic.Group; + init(ecModel: GlobalModel, api: ExtensionAPI) { const symbolDraw = new SymbolDraw(); const lineDraw = new LineDraw(); const group = this.group; - + const mainGroup = new graphic.Group(); this._controller = new RoamController(api.getZr()); this._controllerHost = { - target: group + target: mainGroup } as RoamControllerHost; - group.add(symbolDraw.group); - group.add(lineDraw.group); + mainGroup.add(symbolDraw.group); + mainGroup.add(lineDraw.group); + group.add(mainGroup); this._symbolDraw = symbolDraw; this._lineDraw = lineDraw; + this._mainGroup = mainGroup; + this._firstRender = true; } render(seriesModel: GraphSeriesModel, ecModel: GlobalModel, api: ExtensionAPI) { const coordSys = seriesModel.coordinateSystem; + let isForceLayout = false; this._model = seriesModel; const symbolDraw = this._symbolDraw; const lineDraw = this._lineDraw; - - const group = this.group; - if (isViewCoordSys(coordSys)) { const groupNewProp = { x: coordSys.x, y: coordSys.y, scaleX: coordSys.scaleX, scaleY: coordSys.scaleY }; if (this._firstRender) { - group.attr(groupNewProp); + this._mainGroup.attr(groupNewProp); } else { - graphic.updateProps(group, groupNewProp, seriesModel); + graphic.updateProps(this._mainGroup, groupNewProp, seriesModel); } } // Fix edge contact point with node @@ -121,7 +127,8 @@ class GraphView extends ChartView { const forceLayout = seriesModel.forceLayout; const layoutAnimation = seriesModel.get(['force', 'layoutAnimation']); if (forceLayout) { - this._startForceLayoutIteration(forceLayout, layoutAnimation); + isForceLayout = true; + this._startForceLayoutIteration(forceLayout, api, layoutAnimation); } const layout = seriesModel.get('layout'); @@ -144,7 +151,7 @@ class GraphView extends ChartView { case 'force': forceLayout.warmUp(); !this._layouting - && this._startForceLayoutIteration(forceLayout, layoutAnimation); + && this._startForceLayoutIteration(forceLayout, api, layoutAnimation); forceLayout.setFixed(idx); // Write position back to layout data.setItemLayout(idx, [el.x, el.y]); @@ -205,6 +212,7 @@ class GraphView extends ChartView { }); this._firstRender = false; + isForceLayout || this._renderThumbnail(seriesModel, api, this._symbolDraw, this._lineDraw); } dispose() { @@ -214,11 +222,15 @@ class GraphView extends ChartView { _startForceLayoutIteration( forceLayout: GraphSeriesModel['forceLayout'], + api: ExtensionAPI, layoutAnimation?: boolean ) { const self = this; (function step() { forceLayout.step(function (stopped) { + if (stopped) { + self._renderThumbnail(self._model, api, self._symbolDraw, self._lineDraw); + } self.updateLayout(self._model); (self._layouting = !stopped) && ( layoutAnimation @@ -264,6 +276,7 @@ class GraphView extends ChartView { dx: e.dx, dy: e.dy }); + this._thumbnail._updateSelectedRect('pan'); }) .on('zoom', (e) => { roamHelper.updateViewOnZoom(controllerHost, e.scale, e.originX, e.originY); @@ -279,6 +292,7 @@ class GraphView extends ChartView { this._lineDraw.updateLayout(); // Only update label layout on zoom api.updateLabelLayout(); + this._thumbnail._updateSelectedRect('zoom'); }); } @@ -303,7 +317,21 @@ class GraphView extends ChartView { remove(ecModel: GlobalModel, api: ExtensionAPI) { this._symbolDraw && this._symbolDraw.remove(); this._lineDraw && this._lineDraw.remove(); + this._thumbnail && this.group.remove(this._thumbnail.group); + } + + private _renderThumbnail( + seriesModel: GraphSeriesModel, + api: ExtensionAPI, + symbolDraw: SymbolDraw, + lineDraw: LineDraw + ) { + if (this._thumbnail) { + this.group.remove(this._thumbnail.group); + } + (this._thumbnail = new Thumbnail(this.group)).render(seriesModel, api, symbolDraw, lineDraw, this._mainGroup); } } export default GraphView; + diff --git a/src/chart/graph/Thumbnail.ts b/src/chart/graph/Thumbnail.ts new file mode 100644 index 0000000000..bb2f2dab8e --- /dev/null +++ b/src/chart/graph/Thumbnail.ts @@ -0,0 +1,265 @@ +import * as graphic from '../../util/graphic'; +import ExtensionAPI from '../../core/ExtensionAPI'; +import * as layout from '../../util/layout'; +import { BoxLayoutOptionMixin } from '../../util/types'; +import SymbolClz from '../helper/Symbol'; +import ECLinePath from '../helper/LinePath'; +import GraphSeriesModel from './GraphSeries'; +import * as zrUtil from 'zrender/src/core/util'; +import View from '../../coord/View'; +import SymbolDraw from '../helper/SymbolDraw'; +import LineDraw from '../helper/LineDraw'; + +interface LayoutParams { + pos: BoxLayoutOptionMixin + box: { + width: number, + height: number + } +} + +function getViewRect(layoutParams: LayoutParams, wrapperShpae: {width: number, height: number}, aspect: number) { + const option = zrUtil.extend(layoutParams, { + aspect: aspect + }); + return layout.getLayoutRect(option, { + width: wrapperShpae.width, + height: wrapperShpae.height + }); +} + +class Thumbnail { + + group = new graphic.Group(); + + private _selectedRect: graphic.Rect; + + private _layoutParams: LayoutParams; + + private _graphModel: GraphSeriesModel; + + private _wrapper: graphic.Rect; + + private _coords: View; + + constructor(containerGroup: graphic.Group) { + containerGroup.add(this.group); + } + + render( + seriesModel: GraphSeriesModel, + api: ExtensionAPI, + symbolDraw: SymbolDraw, + lineDraw: LineDraw, + graph: graphic.Group + ) { + const model = seriesModel.getModel('thumbnail'); + const group = this.group; + group.removeAll(); + if (!model.get('show')) { + return; + } + this._graphModel = seriesModel; + + const symbolNodes = symbolDraw.group.children(); + const lineNodes = lineDraw.group.children(); + + const lineGroup = new graphic.Group(); + const symbolGroup = new graphic.Group(); + + const zoom = seriesModel.get('zoom', true); + + const itemStyleModel = model.getModel('itemStyle'); + const itemStyle = itemStyleModel.getItemStyle(); + const selectStyleModel = model.getModel('selectedAreaStyle'); + const selectStyle = selectStyleModel.getItemStyle(); + const thumbnailHeight = this._handleThumbnailShape(model.get('height', true), api, 'height'); + const thumbnailWidth = this._handleThumbnailShape(model.get('width', true), api, 'width'); + + this._layoutParams = { + pos: { + left: model.get('left'), + right: model.get('right'), + top: model.get('top'), + bottom: model.get('bottom') + }, + box: { + width: api.getWidth(), + height: api.getHeight() + } + }; + + const layoutParams = this._layoutParams; + + const thumbnailGroup = new graphic.Group(); + + for (const node of symbolNodes) { + const sub = (node as graphic.Group).children()[0]; + const x = (node as SymbolClz).x; + const y = (node as SymbolClz).y; + const subShape = zrUtil.clone((sub as graphic.Path).shape); + const shape = zrUtil.extend(subShape, { + width: sub.scaleX, + height: sub.scaleY, + x: x - sub.scaleX / 2, + y: y - sub.scaleY / 2 + }); + const style = zrUtil.clone((sub as graphic.Path).style); + const subThumbnail = new (sub as any).constructor({ + shape, + style, + z2: 151 + }); + symbolGroup.add(subThumbnail); + } + + for (const node of lineNodes) { + const line = (node as graphic.Group).children()[0]; + const style = zrUtil.clone((line as ECLinePath).style); + const shape = zrUtil.clone((line as ECLinePath).shape); + const lineThumbnail = new ECLinePath({ + style, + shape, + z2: 151 + }); + lineGroup.add(lineThumbnail); + } + + thumbnailGroup.add(symbolGroup); + thumbnailGroup.add(lineGroup); + + const thumbnailWrapper = new graphic.Rect({ + style: itemStyle, + shape: { + height: thumbnailHeight, + width: thumbnailWidth + }, + z2: 150 + }); + + this._wrapper = thumbnailWrapper; + + group.add(thumbnailGroup); + group.add(thumbnailWrapper); + + layout.positionElement(thumbnailWrapper, layoutParams.pos, layoutParams.box); + + const coordSys = new View(); + const boundingRect = graph.getBoundingRect(); + coordSys.setBoundingRect(boundingRect.x, boundingRect.y, boundingRect.width, boundingRect.height); + + this._coords = coordSys; + + const viewRect = getViewRect(layoutParams, thumbnailWrapper.shape, boundingRect.width / boundingRect.height); + + const scaleX = viewRect.width / boundingRect.width; + const scaleY = viewRect.height / boundingRect.height; + const offsetX = (thumbnailWidth - boundingRect.width * scaleX) / 2; + const offsetY = (thumbnailHeight - boundingRect.height * scaleY) / 2; + + + coordSys.setViewRect( + thumbnailWrapper.x + offsetX, + thumbnailWrapper.y + offsetY, + viewRect.width, + viewRect.height + ); + + const groupNewProp = { + x: coordSys.x, + y: coordSys.y, + scaleX, + scaleY + }; + + thumbnailGroup.attr(groupNewProp); + + this._selectedRect = new graphic.Rect({ + style: selectStyle, + x: coordSys.x, + y: coordSys.y, + // ignore: true, + z2: 152 + }); + + group.add(this._selectedRect); + + if (zoom > 1) { + this._updateSelectedRect('init'); + } + } + + _updateSelectedRect(type: 'zoom' | 'pan' | 'init') { + const getNewRect = (min = false) => { + const {height, width} = this._layoutParams.box; + const origin = [0, 0]; + const end = [width, height]; + const originData = this._graphModel.coordinateSystem.pointToData(origin); + const endData = this._graphModel.coordinateSystem.pointToData(end); + + const thumbnailMain = this._coords.dataToPoint(originData as number[]); + const thumbnailMax = this._coords.dataToPoint(endData as number[]); + + const newWidth = thumbnailMax[0] - thumbnailMain[0]; + const newHeight = thumbnailMax[1] - thumbnailMain[1]; + + rect.x = thumbnailMain[0]; + rect.y = thumbnailMain[1]; + + rect.shape.width = newWidth; + rect.shape.height = newHeight; + + if (min === false) { + rect.dirty(); + } + }; + const rect = this._selectedRect; + + const {x: rMinX, y: rMinY, shape: {width: rWidth, height: rHeight}} = rect; + const {x: wMinX, y: wMinY, shape: {width: wWidth, height: wHeight}} = this._wrapper; + + const [rMaxX, rMaxY] = [rMinX + rWidth, rMinY + rHeight]; + const [wMaxX, wMaxY] = [wMinX + wWidth, wMinY + wHeight]; + + if (type === 'init') { + rect.show(); + getNewRect(); + return; + } + else if (type === 'zoom' && rWidth < wWidth / 10) { + getNewRect(true); + return; + } + if (rMinX > wMinX && rMinY > wMinY && rMaxX < wMaxX && rMaxY < wMaxY) { + this._selectedRect.show(); + // this._selectedRect.removeClipPath(); + } + else { + // this._selectedRect.removeClipPath(); + // this._selectedRect.setClipPath(this._wrapper); + this._selectedRect.hide(); + } + + getNewRect(); + } + + _handleThumbnailShape(size: number | string, api: ExtensionAPI, type: 'height' | 'width') { + if (typeof size === 'number') { + return size; + } + else { + const len = size.length; + if (size.includes('%') && size.indexOf('%') === len - 1) { + const screenSize = type === 'height' ? api.getHeight() : api.getWidth(); + return +size.slice(0, len - 1) * screenSize / 100; + } + return 200; + } + } + + remove() { + this.group.removeAll(); + } +} + +export default Thumbnail; \ No newline at end of file diff --git a/test/graph-thumbnail.html b/test/graph-thumbnail.html new file mode 100644 index 0000000000..aa19a6813e --- /dev/null +++ b/test/graph-thumbnail.html @@ -0,0 +1,579 @@ + + + + + + + + + + + + graph-thumbnail.html + + + +
+
+
+
+ + + + + + + + + + \ No newline at end of file