Skip to content

Commit

Permalink
fix(module: graph): add minimap
Browse files Browse the repository at this point in the history
  • Loading branch information
simplejason committed Nov 15, 2020
1 parent aa255fd commit f09c978
Show file tree
Hide file tree
Showing 11 changed files with 7,234 additions and 10 deletions.
277 changes: 277 additions & 0 deletions components/graph/core/minimap.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,277 @@
/**
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://github.com/NG-ZORRO/ng-zorro-antd/blob/master/LICENSE
*/

import { ZoomBehavior, ZoomTransform } from 'd3';
import { select } from 'd3-selection';
import { NzSafeAny } from 'ng-zorro-antd/core/types';

// tslint:disable-next-line:no-duplicate-imports
import * as d3 from 'd3';

const FRAC_VIEWPOINT_AREA = 0.8;

export class Minimap {
private minimap: HTMLElement;
private canvas: HTMLCanvasElement;
private canvasRect: ClientRect;
private canvasBuffer: HTMLCanvasElement;
private download!: HTMLLinkElement;
private downloadCanvas: HTMLCanvasElement;
private minimapSvg: SVGSVGElement;
private viewpoint: SVGRectElement;
private scaleMinimap!: number;
private scaleMain!: number;
private maxWandH: number;
private translate!: [number, number];
private viewpointCoord: { x: number; y: number };
private minimapSize!: { width: number; height: number };
private labelPadding: number;

private svg: SVGSVGElement;
private zoomG: SVGGElement;
private mainZoom: ZoomBehavior<NzSafeAny, NzSafeAny>;

constructor(
svg: SVGSVGElement,
zoomG: SVGGElement,
mainZoom: ZoomBehavior<NzSafeAny, NzSafeAny>,
minimap: HTMLElement,
maxWandH: number,
labelPadding: number
) {
this.svg = svg;
this.labelPadding = labelPadding;
this.zoomG = zoomG;
this.mainZoom = mainZoom;
this.maxWandH = maxWandH;
const minimapElement = select(minimap);
const minimapSvgElement = minimapElement.select('svg');
const viewpointElement = minimapSvgElement.select('rect');
this.canvas = minimapElement.select('canvas.first').node() as HTMLCanvasElement;
this.canvasRect = this.canvas.getBoundingClientRect();

const handleEvent = (event: NzSafeAny): void => {
const minimapOffset = this.minimapOffset();
const width = Number(viewpointElement.attr('width'));
const height = Number(viewpointElement.attr('height'));
// @ts-ignore
const clickCoords = d3.pointer(event, minimapSvgElement.node() as NzSafeAny);
this.viewpointCoord.x = clickCoords[0] - width / 2 - minimapOffset.x;
this.viewpointCoord.y = clickCoords[1] - height / 2 - minimapOffset.y;
this.updateViewpoint();
};
this.viewpointCoord = { x: 0, y: 0 };
const drag = d3.drag().subject(Object).on('drag', handleEvent);
// @ts-ignore
viewpointElement.datum(this.viewpointCoord as NzSafeAny).call(drag);

// Make the minimap clickable.
minimapSvgElement.on('click', event => {
if ((event as Event).defaultPrevented) {
// This click was part of a drag event, so suppress it.
return;
}
handleEvent(event);
});
this.viewpoint = viewpointElement.node() as SVGRectElement;
this.minimapSvg = minimapSvgElement.node() as SVGSVGElement;
this.minimap = minimap;
this.canvasBuffer = minimapElement.select('canvas.second').node() as HTMLCanvasElement;
this.downloadCanvas = minimapElement.select('canvas.download').node() as HTMLCanvasElement;
d3.select(this.downloadCanvas).style('display', 'none');
this.update();
}

private minimapOffset(): { x: number; y: number } {
return {
x: (this.canvasRect.width - this.minimapSize.width) / 2,
y: (this.canvasRect.height - this.minimapSize.height) / 2
};
}

private updateViewpoint(): void {
// Update the coordinates of the viewpoint rectangle.
d3.select(this.viewpoint).attr('x', this.viewpointCoord.x).attr('y', this.viewpointCoord.y);
// Update the translation vector of the main svg to reflect the
// new viewpoint.
const mainX = (-this.viewpointCoord.x * this.scaleMain) / this.scaleMinimap;
const mainY = (-this.viewpointCoord.y * this.scaleMain) / this.scaleMinimap;
d3.select(this.svg).call(this.mainZoom.transform, d3.zoomIdentity.translate(mainX, mainY).scale(this.scaleMain));
}

update(): void {
let sceneSize = null;
try {
// Get the size of the entire scene.
sceneSize = this.zoomG.getBBox();
if (sceneSize.width === 0) {
// There is no scene anymore. We have been detached from the dom.
return;
}
} catch (e) {
// Firefox produced NS_ERROR_FAILURE if we have been
// detached from the dom.
return;
}
const downloadSelection = d3.select('#graphdownload');
this.download = downloadSelection.node() as HTMLLinkElement;
downloadSelection.on('click', () => {
this.download.href = this.downloadCanvas.toDataURL('image/png');
});

const svgSelection = d3.select(this.svg);
// Read all the style rules in the document and embed them into the svg.
// The svg needs to be self contained, i.e. all the style rules need to be
// embedded so the canvas output matches the origin.
let stylesText = '';

for (const k of new Array(document.styleSheets.length).keys()) {
try {
const cssRules = (document.styleSheets[k] as NzSafeAny).cssRules || (document.styleSheets[k] as NzSafeAny).rules;
if (cssRules == null) {
continue;
}
for (const i of new Array(cssRules.length).keys()) {
// Remove tf-* selectors from the styles.
stylesText += cssRules[i].cssText.replace(/ ?tf-[\w-]+ ?/g, '') + '\n';
}
} catch (e) {
if (e.name !== 'SecurityError') {
throw e;
}
}
}

// Temporarily add the css rules to the main svg.
const svgStyle = svgSelection.append('style');
svgStyle.text(stylesText);

// Temporarily remove the zoom/pan transform from the main svg since we
// want the minimap to show a zoomed-out and centered view.
const zoomGSelection = d3.select(this.zoomG);
const zoomTransform = zoomGSelection.attr('transform');
zoomGSelection.attr('transform', null);

// Since we add padding, account for that here.
sceneSize.height += this.labelPadding * 2;
sceneSize.width += this.labelPadding * 2;

// Temporarily assign an explicit width/height to the main svg, since
// it doesn't have one (uses flex-box), but we need it for the canvas
// to work.
svgSelection.attr('width', sceneSize.width).attr('height', sceneSize.height);

// Since the content inside the svg changed (e.g. a node was expanded),
// the aspect ratio have also changed. Thus, we need to update the scale
// factor of the minimap. The scale factor is determined such that both
// the width and height of the minimap are <= maximum specified w/h.
this.scaleMinimap = this.maxWandH / Math.max(sceneSize.width, sceneSize.height);
this.minimapSize = {
width: sceneSize.width * this.scaleMinimap,
height: sceneSize.height * this.scaleMinimap
};

const minimapOffset = this.minimapOffset();

// Update the size of the minimap's svg, the buffer canvas and the
// viewpoint rect.
d3.select(this.minimapSvg).attr(this.minimapSize as NzSafeAny);
d3.select(this.canvasBuffer).attr(this.minimapSize as NzSafeAny);

// Download canvas width and height are multiples of the style width and
// height in order to increase pixel density of the PNG for clarity.
const downloadCanvasSelection = d3.select(this.downloadCanvas);
downloadCanvasSelection.style('width', sceneSize.width);
downloadCanvasSelection.style('height', sceneSize.height);
downloadCanvasSelection.attr('width', sceneSize.width * 3);
downloadCanvasSelection.attr('height', sceneSize.height * 3);

if (this.translate != null && this.zoom != null) {
// Update the viewpoint rectangle shape since the aspect ratio of the
// map has changed.
requestAnimationFrame(() => this.zoom());
}

// Serialize the main svg to a string which will be used as the rendering
// content for the canvas.
const svgXml = new XMLSerializer().serializeToString(this.svg);

// Now that the svg is serialized for rendering, remove the temporarily
// assigned styles, explicit width and height and bring back the pan/zoom
// transform.
svgStyle.remove();
svgSelection.attr('width', '100%').attr('height', '100%');

zoomGSelection.attr('transform', zoomTransform);

const image = new Image();
image.onload = () => {
// Draw the svg content onto the buffer canvas.
const context = this.canvasBuffer.getContext('2d');
context!.clearRect(0, 0, this.canvasBuffer.width, this.canvasBuffer.height);

context!.drawImage(image, minimapOffset.x, minimapOffset.y, this.minimapSize.width, this.minimapSize.height);
requestAnimationFrame(() => {
// Hide the old canvas and show the new buffer canvas.
d3.select(this.canvasBuffer).style('display', null);
d3.select(this.canvas).style('display', 'none');
// Swap the two canvases.
[this.canvas, this.canvasBuffer] = [this.canvasBuffer, this.canvas];
});
const downloadContext = this.downloadCanvas.getContext('2d');
downloadContext!.clearRect(0, 0, this.downloadCanvas.width, this.downloadCanvas.height);
downloadContext!.drawImage(image, 0, 0, this.downloadCanvas.width, this.downloadCanvas.height);
};
image.src = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(svgXml);
}

/**
* Handles changes in zooming/panning. Should be called from the main svg
* to notify that a zoom/pan was performed and this minimap will update it's
* viewpoint rectangle.
*
* @param translate The translate vector, or none to use the last used one.
* @param scale The scaling factor, or none to use the last used one.
*/
zoom(transform?: ZoomTransform): void {
if (this.scaleMinimap == null) {
// Scene is not ready yet.
return;
}
// Update the new translate and scale params, only if specified.
if (transform) {
this.translate = [transform.x, transform.y];
this.scaleMain = transform.k;
}

// Update the location of the viewpoint rectangle.
const svgRect = this.svg.getBoundingClientRect();
const minimapOffset = this.minimapOffset();
const viewpointSelection = d3.select(this.viewpoint);
this.viewpointCoord.x = (-this.translate[0] * this.scaleMinimap) / this.scaleMain;
this.viewpointCoord.y = (-this.translate[1] * this.scaleMinimap) / this.scaleMain;
const viewpointWidth = (svgRect.width * this.scaleMinimap) / this.scaleMain;
const viewpointHeight = (svgRect.height * this.scaleMinimap) / this.scaleMain;
viewpointSelection
.attr('x', this.viewpointCoord.x + minimapOffset.x)
.attr('y', this.viewpointCoord.y + minimapOffset.y)
.attr('width', viewpointWidth)
.attr('height', viewpointHeight);
// Show/hide the minimap depending on the viewpoint area as fraction of the
// whole minimap.
const mapWidth = this.minimapSize.width;
const mapHeight = this.minimapSize.height;
const x = this.viewpointCoord.x;
const y = this.viewpointCoord.y;
const w = Math.min(Math.max(0, x + viewpointWidth), mapWidth) - Math.min(Math.max(0, x), mapWidth);
const h = Math.min(Math.max(0, y + viewpointHeight), mapHeight) - Math.min(Math.max(0, y), mapHeight);
const fracIntersect = (w * h) / (mapWidth * mapHeight);
if (fracIntersect < FRAC_VIEWPOINT_AREA) {
this.minimap.classList.remove('hidden');
} else {
this.minimap.classList.add('hidden');
}
}
}
2 changes: 1 addition & 1 deletion components/graph/demo/basic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ export class NzDemoGraphBasicComponent {
{ id: '12.Calc' },
{ id: '13.JoinTable' },
{ id: '14.Calc' },
{ id: '16.GroupAggregate', label: 'another name' },
{ id: '16.GroupAggregate' },
{ id: '17.Calc' },
{ id: '18.Calc' },
{ id: '19.JoinTable' },
Expand Down
74 changes: 74 additions & 0 deletions components/graph/graph-minimap.component.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
/**
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://github.com/NG-ZORRO/ng-zorro-antd/blob/master/LICENSE
*/

import { Component, ElementRef, OnDestroy, OnInit } from '@angular/core';
import { ZoomBehavior, ZoomTransform } from 'd3';
import { NzSafeAny } from 'ng-zorro-antd/core/types';
import { Subscription } from 'rxjs';
import { Minimap } from './core/minimap';
import { NZ_LAYOUT_SETTING } from './interface';

@Component({
selector: 'nz-graph-minimap',
template: `
<svg>
<defs>
<filter id="minimapDropShadow" x="-20%" y="-20%" width="150%" height="150%">
<feOffset result="offOut" in="SourceGraphic" dx="1" dy="1"></feOffset>
<feColorMatrix
result="matrixOut"
in="offOut"
type="matrix"
values="0.1 0 0 0 0 0 0.1 0 0 0 0 0 0.1 0 0 0 0 0 0.5 0"
></feColorMatrix>
<feGaussianBlur result="blurOut" in="matrixOut" stdDeviation="2"></feGaussianBlur>
<feBlend in="SourceGraphic" in2="blurOut" mode="normal"></feBlend>
</filter>
</defs>
<rect></rect>
</svg>
<canvas class="first"></canvas>
<!-- Additional canvas to use as buffer to avoid flickering between updates -->
<canvas class="second"></canvas>
<canvas class="download"></canvas>
`,
host: {
'[class.ant-graph-minimap]': 'true'
}
})
export class NzGraphMinimapComponent implements OnInit, OnDestroy {
minimap?: Minimap;
zoomInit$ = new Subscription();
constructor(private elementRef: ElementRef<HTMLElement>) {}

ngOnInit(): void {}

ngOnDestroy(): void {
this.zoomInit$.unsubscribe();
}

init(svgEle: SVGSVGElement, zoomEle: SVGGElement, zoomBehavior: ZoomBehavior<NzSafeAny, NzSafeAny>): void {
this.minimap = new Minimap(
svgEle,
zoomEle,
zoomBehavior,
this.elementRef.nativeElement,
NZ_LAYOUT_SETTING.minimap.size,
NZ_LAYOUT_SETTING.subscene.meta.labelHeight
);
}

zoom(transform: ZoomTransform): void {
if (this.minimap) {
this.minimap.zoom(transform);
}
}

update(): void {
if (this.minimap) {
this.minimap.update();
}
}
}
6 changes: 4 additions & 2 deletions components/graph/graph-node.directive.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
*/

import { animate, AnimationBuilder, AnimationFactory, AnimationPlayer, group, query, style } from '@angular/animations';
import { AfterViewInit, Directive, ElementRef, EventEmitter, HostListener, Input, Output } from '@angular/core';
import { AfterViewInit, Directive, ElementRef, EventEmitter, HostListener, Input, Output, Renderer2 } from '@angular/core';
import { Observable, Subject } from 'rxjs';
import { NzGraphGroupNode, NzGraphNode } from './interface';

Expand Down Expand Up @@ -37,7 +37,7 @@ export class NzGraphNodeDirective implements AfterViewInit {

private animationPlayer: AnimationPlayer | null = null;

constructor(private el: ElementRef, private builder: AnimationBuilder) {}
constructor(private el: ElementRef, private builder: AnimationBuilder, private renderer2: Renderer2) {}

makeAnimation(isFirstChange: boolean = false): Observable<void> {
if (this.animationPlayer) {
Expand Down Expand Up @@ -87,6 +87,8 @@ export class NzGraphNodeDirective implements AfterViewInit {
this.animationPlayer.onDone(() => {
done$.next();
done$.complete();
// Need this for canvas for now.
this.renderer2.setAttribute(this.el.nativeElement, 'transform', `translate(${cur.x}, ${cur.y})`);
});
return done$.asObservable();
}
Expand Down
Loading

0 comments on commit f09c978

Please sign in to comment.